diff --git a/.gitignore b/.gitignore index 172d94c7ec..4a47d86b6e 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ vendor/bundle *.js.map *.zip .idea/ +.history diff --git a/assets/js/theme/product.js b/assets/js/theme/product.js index a81d23e527..578db3e0b3 100644 --- a/assets/js/theme/product.js +++ b/assets/js/theme/product.js @@ -8,6 +8,7 @@ import ProductDetails from './common/product-details'; import videoGallery from './product/video-gallery'; import { classifyForm } from './common/utils/form-utils'; import modalFactory from './global/modal'; +import applyRecommendations from './product/recommendations/recommendations'; export default class Product extends PageManager { constructor(context) { @@ -16,6 +17,7 @@ export default class Product extends PageManager { this.$reviewLink = $('[data-reveal-id="modal-review-form"]'); this.$bulkPricingLink = $('[data-reveal-id="modal-bulk-pricing"]'); this.reviewModal = modalFactory('#modal-review-form')[0]; + this.$relatedProductsTabContent = $('#tab-related'); } onReady() { @@ -59,6 +61,16 @@ export default class Product extends PageManager { }); this.productReviewHandler(); + + // Start product recommendations flow + applyRecommendations( + this.$relatedProductsTabContent, + { + productId: this.context.productId, + themeSettings: this.context.themeSettings, + storefrontAPIToken: this.context.settings.storefront_api.token, + }, + ); } ariaDescribeReviewInputs($form) { diff --git a/assets/js/theme/product/recommendations/README.md b/assets/js/theme/product/recommendations/README.md new file mode 100644 index 0000000000..880d53b4d3 --- /dev/null +++ b/assets/js/theme/product/recommendations/README.md @@ -0,0 +1,68 @@ +### **Description** + +This Cornerstone theme modification introduces recommendations flow to UX. +Below you could find description of consecutive steps happening in browser during the period user lands on product page +and see products in "Related products" section. + +### **Theme modifications** + +JavaScript code for running recommendations flow resides in `/assets/js/theme/product/recommendations` folder. +In order execute recommendations flow `applyRecommendations()` method from `recommendations.js` is invoked. + +Changes made to the theme files except `/assets/js/theme/product/recommendations` folder: +1. Overlay block added to `/templates/components/products/tabs.html` in order to show spinner while +recommendations are being loaded. +Also, "recommendations" class added to "Related products" tab element and css is slightly overridden +for it in `/assets/scss/recommendations.scss`. + +2. Data attributes (`data-token-url="add-to-cart"` or `data-token-url="product-detail-page"`) +are added to anchor elements in `templates/components/products/card.html` +in order to be able to select elements in runtime and add static/recommendation token to urls. +This is used by backend to recognize requests and calculate click-through rate during recommendation and default flows. + +3. Some data is injected inside `templates/pages/product.html` in order to be accessible in js context +inside `/assets/js/theme/product.js`. + +4. `/assets/js/theme/product.js` is tweaked to invoke recommendations flow inside `onReady()` method. + +### **Algorithm** + +1. User goes to product detail view and browser sends request for a product page. +`/templates/pages/product.html` is rendered server-side with some related products markup inside. +In addition, with the response visitor group cookie `bc_rec_ab` is sent. + +2. Entry point of recommendations flow: `/assets/js/theme/product.js: 59`. +Cookie value with key`bc_rec_ab` is read. + + 2a. If the value corresponds to "Control group" (0) then "static token" assigned to + all "Add To Cart" or "Detailed Product View" links inside each related product card. + The flow is finished. + `/assets/js/theme/product/recommendations/recommendations.js: 159` + + 2b. If the value is equal to "Treatment Group" (1) then execution proceeds to next step. + `/assets/js/theme/product/recommendations/recommendations.js: 116` + +3. Spinner is laid over currently rendered related products. +`/assets/js/theme/product/recommendations/recommendations.js: 124` + +4. Http request to cloud function is made (`/assets/js/theme/product/recommendations/recommendations.js: 23`). +Response should contain recommendation token along with product ids generated by Recommendation AI. +Host of the cloud function is located at `/assets/js/theme/product/recommendations/constants.js`. + + Please, modify `CLOUD_FUNCTION_URL` constant to match an url of your deployed cloud function instance. + Then the theme should be rebuilt by Stencil and uploaded to the store. + Also, please, don't forget to setup `Access-Control-Allow-Headers` header which allows your frontend's + domain make cross-origin HTTP requests to cloud function. + +5. If request is successful and product ids are received, +another GraphQL request is made to the backend in order to get product details (name, image, price, etc.). +`/assets/js/theme/product/recommendations/recommendations.js: 58` + +6. If GraphQL request is successful, markup for product cards elements is generated applying received data. +Recommendation token (from p. 4) is attached to "Add To Cart" or "Detailed Product View" links +in each generated product card. +Finally, elements are inserted to DOM. +`/assets/js/theme/product/recommendations/recommendations-carousel.js: 94` + +7. Spinner is hidden and newly generated recommended products are shown. +In case of error at steps 4-6, spinner is hidden and initial related products are shown. diff --git a/assets/js/theme/product/recommendations/constants.js b/assets/js/theme/product/recommendations/constants.js new file mode 100644 index 0000000000..5873b31199 --- /dev/null +++ b/assets/js/theme/product/recommendations/constants.js @@ -0,0 +1,4 @@ +export const NUM_OF_PRODUCTS = 6; +export const EVENT_TYPE = 'detail-page-view'; +export const SERVICE_CONFIG_ID = 'others-you-may-like-ctr-serving-config'; +export const RECOM_TOKEN_PARAM = 'attributionToken'; diff --git a/assets/js/theme/product/recommendations/graphql.js b/assets/js/theme/product/recommendations/graphql.js new file mode 100644 index 0000000000..da788de256 --- /dev/null +++ b/assets/js/theme/product/recommendations/graphql.js @@ -0,0 +1,9 @@ +import request from './http'; + +export default function gql(query, variables, token) { + return request('POST', '/graphql', JSON.stringify({ query, variables }), { + 'Content-Type': 'application/json', + // eslint-disable-next-line quote-props + Authorization: `Bearer ${token}`, + }); +} diff --git a/assets/js/theme/product/recommendations/http.js b/assets/js/theme/product/recommendations/http.js new file mode 100644 index 0000000000..fac767d324 --- /dev/null +++ b/assets/js/theme/product/recommendations/http.js @@ -0,0 +1,22 @@ +export default function request(method, url, data, headers, options) { + const xhr = new XMLHttpRequest(); + return new Promise((resolve, reject) => { + xhr.onreadystatechange = function onReadyStateChange() { + if (xhr.readyState !== 4) return; + if (xhr.status >= 200 && xhr.status < 300) { + resolve(xhr.response); + } else { + reject(new Error(xhr)); + } + }; + xhr.withCredentials = (options && options.withCredentials) || false; + xhr.responseType = (options && options.responseType) || 'json'; + xhr.open(method, url); + + Object.keys(headers || {}).forEach((key) => { + xhr.setRequestHeader(key, headers[key]); + }); + + xhr.send(data); + }); +} diff --git a/assets/js/theme/product/recommendations/recommendations-carousel.js b/assets/js/theme/product/recommendations/recommendations-carousel.js new file mode 100644 index 0000000000..033d136613 --- /dev/null +++ b/assets/js/theme/product/recommendations/recommendations-carousel.js @@ -0,0 +1,142 @@ +/* eslint-disable indent */ +import { addQueryParams } from './utils'; +import { RECOM_TOKEN_PARAM, NUM_OF_PRODUCTS } from './constants'; + +function renderPrice(node, themeSettings) { + const { price, retailPrice } = node.prices || { price: {} }; + return ` + +
+ + ${themeSettings['pdp-price-label']} + + ${price.value} ${price.currencyCode} +
+ `; +} + +function renderRestrictToLogin() { + return '

Log in for pricing

'; +} + +function renderCard(node, options) { + const { themeSettings, attributionToken } = options; + const categories = node.categories.edges.map(({ node: cNode }) => cNode.name).join(','); + const productUrl = addQueryParams(node.path, { [RECOM_TOKEN_PARAM]: attributionToken }); + const addToCartUrl = addQueryParams(node.addToCartUrl, { [RECOM_TOKEN_PARAM]: attributionToken }); + + return `
+
+
+ +
+ ${node.defaultImage ? + `${node.name}` : '' + } + +
+
+
+
+ ${themeSettings.show_product_quick_view + ? `Quick view` + : ''} + Add to Cart +
+
+
+
+ ${node.brand && node.brand.name ? `

${node.brand.name}

` : ''} +

+ ${node.name} +

+
+ ${themeSettings.restrict_to_login ? renderRestrictToLogin() : renderPrice(node, themeSettings)} +
+
+
+
`; +} + +function createFallbackContainer(carousel) { + const container = $('[itemscope] > .tabs-contents'); + const tabs = $('[itemscope] > .tabs'); + tabs.html(` +
  • + Related products +
  • + `); + container.html(` + + `); +} + +export default function injectRecommendations(products, el, options) { + const cards = products + .slice(0, NUM_OF_PRODUCTS) + .map((product) => renderCard(product, options)) + .join(''); + + const carousel = ` +
    + ${cards} +
    `; + // eslint-disable-next-line no-param-reassign + if (!el.get(0)) { + createFallbackContainer(carousel); + } else { + el.html(carousel); + } +} diff --git a/assets/js/theme/product/recommendations/recommendations.js b/assets/js/theme/product/recommendations/recommendations.js new file mode 100644 index 0000000000..ac6770a0ee --- /dev/null +++ b/assets/js/theme/product/recommendations/recommendations.js @@ -0,0 +1,115 @@ +import gql from './graphql'; +import { EVENT_TYPE, NUM_OF_PRODUCTS, SERVICE_CONFIG_ID } from './constants'; +import injectRecommendations from './recommendations-carousel'; +import { showOverlay, hideOverlay, getSizeFromThemeSettings } from './utils'; + +/* + * Invokes graphql query + * @param {string} id - product id + * @param {string} storefrontAPIToken - token from settings + * @param {string} imageSize - e.g. '500x569' + * @param {number} pageSize - number of products to be fetched + * returns {Object} + * */ +function getRecommendations(id, serviceConfigId, storefrontAPIToken, imageSize, pageSize, validateOnly = false) { + return gql( + `query ProductRecommendations($id: Int!, $includeTax: Boolean, $eventType: String!, $pageSize: Int!, $serviceConfigId: String!, $validateOnly: Boolean!) { + site { + apiExtensions { + googleRetailApiPrediction( + pageSize: $pageSize + userEvent: { + eventType: $eventType, + productDetails: [{ entityId: $id, count: 1 }] + } + servingConfigId: $serviceConfigId + validateOnly: $validateOnly + ) { + attributionToken + results { + name + entityId + path + brand { + name + } + prices(includeTax:$includeTax) { + price { + value + currencyCode + } + salePrice { + value + currencyCode + } + retailPrice { + value + currencyCode + } + } + categories { + edges { + node { + name + } + } + } + defaultImage { + urlOriginal + } + addToCartUrl + availability + } + } + } + } + }`, + { + id: Number(id), includeTax: false, eventType: EVENT_TYPE, pageSize, serviceConfigId, validateOnly, + }, + storefrontAPIToken, + ); +} + +/* + * Carries out a flow with recommendations: + * 1. Queries qraphql endpoint for recommended products information + * 2. Creates carousel with product cards in "Related products" section + * @param {Element} el - parent DOM element which carousel with products will be attached to + * @param {Object} options - productId, customerId, settings, themeSettings + * returns {Promise} + * */ +export default function applyRecommendations(el, options) { + const consentManager = window.consentManager; + + // Do not load recommendations if user has opted out of advertising consent category + if (consentManager && !consentManager.preferences.loadPreferences().customPreferences.advertising) return; + + const { productId, themeSettings, storefrontAPIToken } = options; + const imageSize = getSizeFromThemeSettings(themeSettings.productgallery_size); + + showOverlay(el); + + return getRecommendations( + productId, + SERVICE_CONFIG_ID, + storefrontAPIToken, + imageSize, + NUM_OF_PRODUCTS, + ) + .then((response) => { + const { attributionToken, results: products } = response.data.site.apiExtensions.googleRetailApiPrediction; + + injectRecommendations(products, el, { + products, + themeSettings, + productId, + attributionToken, + }); + }) + .catch(err => { + // eslint-disable-next-line no-console + console.error('Error happened during recommendations load', err); + }) + .then(() => hideOverlay(el)); +} diff --git a/assets/js/theme/product/recommendations/recommendations.spec.js b/assets/js/theme/product/recommendations/recommendations.spec.js new file mode 100644 index 0000000000..eecf258ef3 --- /dev/null +++ b/assets/js/theme/product/recommendations/recommendations.spec.js @@ -0,0 +1,128 @@ +import $ from 'jquery'; +import { controlFlow, recommendationsFlow } from './recommendations'; +import * as request from './http'; +import * as gql from './graphql'; +import { STATIC_TOKEN_PARAM, STATIC_TOKEN, RECOM_TOKEN_PARAM } from './constants'; +import { mockProducts, testUrl } from './testData'; + +const addToCartUrls = { + url1: 'http://some.thing/add?one=1', + url2: 'http://some.thing/add/', +}; +const detailViewUrls = { + url1: 'http://some.thing/view?one=1', + url2: 'http://some.thing/view/', +}; + +const productResultLength = 6; +const recommendationToken = 'arbitrary_recommendation_token'; +const mockGetRecommendations = () => () => + Promise.resolve({ + results: [{ id: 1 }, { id: 2 }], + recommendationToken, + }); + +const mockGetProducts = (length = 6) => () => Promise.resolve({ + data: { + site: { + products: { + edges: mockProducts(length), + }, + }, + }, +}); + +const mockDOMElement = (show, hide, html) => ({ + find() { + return { show, hide }; + }, + html, +}); +const defaultOptions = { + themeSettings: { + productgallery_size: '50x50', + show_product_quick_view: true, + }, + settings: {}, +}; + +describe('Recommendations', () => { + describe('Recommendations flow', () => { + let el; + let showElSpy; + let hideElSpy; + let htmlResult; + + beforeEach(() => { + request.default = jest.fn(mockGetRecommendations()); + gql.default = jest.fn(mockGetProducts(productResultLength)); + showElSpy = jest.fn(); + hideElSpy = jest.fn(); + jest.fn(); + el = mockDOMElement(showElSpy, hideElSpy, (html) => { + htmlResult = html; + }); + }); + + afterEach(() => { + htmlResult = undefined; + }); + + it('should show spinner', async () => { + await recommendationsFlow(el, { ...defaultOptions }); + expect(showElSpy).toBeCalledTimes(1); + }); + + it('should hide spinner in successful case', async () => { + await recommendationsFlow(el, { ...defaultOptions }); + expect(hideElSpy).toBeCalledTimes(1); + }); + + it('should hide spinner in error case', async () => { + request.default = jest.fn(() => Promise.reject()); + await recommendationsFlow(el, { ...defaultOptions }); + expect(hideElSpy).toBeCalledTimes(1); + }); + + it('should add recommendation token to urls', async () => { + await recommendationsFlow(el, { ...defaultOptions }); + const $dom = $(htmlResult); + $dom.appendTo(document.body); + + const expectedResult = [].concat(...Array.from(Array(productResultLength)).map(() => [ + `${testUrl}?${RECOM_TOKEN_PARAM}=${recommendationToken}`, + '', // quick view anchor + `${testUrl}?${RECOM_TOKEN_PARAM}=${recommendationToken}`, + `${testUrl}?${RECOM_TOKEN_PARAM}=${recommendationToken}`, + ])); + expect($dom.find('a').get().map(e => e.href)).toEqual(expectedResult); + $dom.remove(); + }); + }); + + describe('Default flow', () => { + it('should add static token to all "Add To Cart" and "Detail View" links', () => { + + const html = `
    + Detail View 1 + Add To Cart 1 + Detail View 2 + Add To Cart 2 + Detail View 3 +
    `; + const $element = $(html); + $element.append(document.body); + + controlFlow($element, { productId: '123' }); + + expect($element.find('a').get().map(e => e.href)).toEqual([ + `${detailViewUrls.url1}&${STATIC_TOKEN_PARAM}=${STATIC_TOKEN}`, + `${addToCartUrls.url1}&${STATIC_TOKEN_PARAM}=${STATIC_TOKEN}`, + `${detailViewUrls.url2}?${STATIC_TOKEN_PARAM}=${STATIC_TOKEN}`, + `${addToCartUrls.url2}?${STATIC_TOKEN_PARAM}=${STATIC_TOKEN}`, + detailViewUrls.url1, + ]); + $element.remove(); + }); + }); +}); diff --git a/assets/js/theme/product/recommendations/testData.js b/assets/js/theme/product/recommendations/testData.js new file mode 100644 index 0000000000..a5ae18c317 --- /dev/null +++ b/assets/js/theme/product/recommendations/testData.js @@ -0,0 +1,50 @@ +export const testUrl = 'https://random.url/one'; +const randomNumber = (max = 25) => Math.floor(Math.random() * max); +const randomString = (length = 7) => Math.random().toString(36).substring(length); +const createProduct = (extendObj = {}) => ({ + node: { + name: randomString(10), + entityId: randomNumber(100), + path: testUrl, + brand: { + name: randomString(10), + }, + prices: { + price: { + value: randomNumber(), + currencyCode: '$', + }, + salePrice: { + value: randomNumber(), + currencyCode: '$', + }, + retailPrice: { + value: randomNumber(), + currencyCode: '$', + }, + }, + categories: { + edges: [ + { + node: { + name: randomString(10), + }, + }, + { + node: { + name: randomString(10), + }, + }, + ], + }, + defaultImage: { + url: testUrl, + }, + addToCartUrl: testUrl, + availability: true, + ...extendObj, + }, +}); + +// eslint-disable-next-line import/prefer-default-export +export const mockProducts = (length = 0) => Array.from(Array(length)).map(createProduct); diff --git a/assets/js/theme/product/recommendations/utils.js b/assets/js/theme/product/recommendations/utils.js new file mode 100644 index 0000000000..9bf7a59db7 --- /dev/null +++ b/assets/js/theme/product/recommendations/utils.js @@ -0,0 +1,34 @@ +export function addQueryParams(url, params = {}) { + const keys = Object.keys(params); + if (!keys.length) return url; + const newParams = keys + .reduce((acc, key) => + acc.concat([`${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`]), []) + .join('&'); + return (url || '').indexOf('?') === -1 ? `${url}?${newParams}` : `${url}&${newParams}`; +} + +export function getSizeFromThemeSettings(setting) { + const size = (setting || '').split('x'); + return { + width: parseInt(size[0], 10) || 500, + height: parseInt(size[1], 10) || 569, + }; +} + +function findOverlay(el) { + return el.find('.loadingOverlay'); +} +export function showOverlay(el) { + const $overlay = findOverlay(el); + if ($overlay) { + $overlay.show(); + } +} + +export function hideOverlay(el) { + const $overlay = findOverlay(el); + if ($overlay) { + $overlay.hide(); + } +} diff --git a/assets/js/theme/product/recommendations/utils.spec.js b/assets/js/theme/product/recommendations/utils.spec.js new file mode 100644 index 0000000000..293adfd181 --- /dev/null +++ b/assets/js/theme/product/recommendations/utils.spec.js @@ -0,0 +1,21 @@ +import { addQueryParams } from './utils'; + +const urls = ['https://test.one/get?one=1', 'https://test.one/']; + +describe('Utils', () => { + it('#addToCartUrls: should return same url if no params added', () => { + expect(addQueryParams(urls[0])).toEqual(urls[0]); + }); + it('#addToCartUrls: should add next params', () => { + expect(addQueryParams(urls[0], { two: 2, three: '3' })) + .toEqual(`${urls[0]}&two=2&three=3`); + }); + it('#addToCartUrls: should add first params', () => { + expect(addQueryParams(urls[1], { two: 2, three: '3' })) + .toEqual(`${urls[1]}?two=2&three=3`); + }); + it('#addToCartUrls: should add one param', () => { + expect(addQueryParams(urls[1], { one: 1 })) + .toEqual(`${urls[1]}?one=1`); + }); +}); diff --git a/assets/scss/recommendations.scss b/assets/scss/recommendations.scss new file mode 100644 index 0000000000..fb7775fa40 --- /dev/null +++ b/assets/scss/recommendations.scss @@ -0,0 +1,5 @@ +#tab-related { + &.recommendations { + position: relative; + } +} diff --git a/lang/en.json b/lang/en.json index 5e5d0af058..bfae3b9663 100755 --- a/lang/en.json +++ b/lang/en.json @@ -724,7 +724,7 @@ "purchase_units": "{quantity, plural, =0{0 units} one {# unit} other {# units}}", "max_purchase_quantity": "Maximum Purchase:", "min_purchase_quantity": "Minimum Purchase:", - "related_products": "Related Products", + "related_products": "AI Product Recommendations", "top": "Most Popular Products", "similar_by_views": "Customers Also Viewed", "featured": "Featured Products", diff --git a/templates/components/products/tabs.html b/templates/components/products/tabs.html index 900b5d23ae..0e91a1d337 100644 --- a/templates/components/products/tabs.html +++ b/templates/components/products/tabs.html @@ -13,8 +13,9 @@
    {{#if product.related_products}} -