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..35c79d6a55
--- /dev/null
+++ b/assets/js/theme/product/recommendations/constants.js
@@ -0,0 +1,3 @@
+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';
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..683eb75d8e
--- /dev/null
+++ b/assets/js/theme/product/recommendations/recommendations-carousel.js
@@ -0,0 +1,141 @@
+/* eslint-disable indent */
+import { NUM_OF_PRODUCTS } from './constants';
+
+function renderPrice(node, themeSettings) {
+ const { price, retailPrice } = node.prices || { price: {} };
+ return `
+
+ ${themeSettings['pdp-retail-price-label']}
+
+ ${retailPrice ? `${retailPrice.value} ${retailPrice.currencyCode}` : ''}
+
+
+
+
+ ${themeSettings['pdp-price-label']}
+
+ ${price.value} ${price.currencyCode}
+
+ `;
+}
+
+function renderRestrictToLogin() {
+ return 'Log in for pricing
';
+}
+
+function renderCard(node, options) {
+ const { themeSettings } = options;
+ const categories = node.categories.edges.map(({ node: cNode }) => cNode.name).join(',');
+ const productUrl = node.path;
+ const addToCartUrl = node.addToCartUrl;
+
+ return ``;
+}
+
+function createFallbackContainer(carousel) {
+ const container = $('[itemscope] > .tabs-contents');
+ const tabs = $('[itemscope] > .tabs');
+ tabs.html(`
+
+ Related products
+
+ `);
+ container.html(`
+
+ ${carousel}
+
+ `);
+}
+
+export default function injectRecommendations(products, el, options) {
+ const cards = products
+ .slice(0, NUM_OF_PRODUCTS)
+ .map((product) => renderCard(product, options))
+ .join('');
+
+ const carousel = `
+ `;
+ // 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..08bdb7ba6d
--- /dev/null
+++ b/assets/js/theme/product/recommendations/recommendations.js
@@ -0,0 +1,117 @@
+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) {
+ const customerPreferences = consentManager.preferences.loadPreferences().customPreferences;
+ if (customerPreferences && !customerPreferences.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 { results: products } = response.data.site.apiExtensions.googleRetailApiPrediction;
+
+ injectRecommendations(products, el, {
+ products,
+ themeSettings,
+ productId,
+ });
+ })
+ .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 = ``;
+ 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..b96647641e
--- /dev/null
+++ b/assets/js/theme/product/recommendations/utils.js
@@ -0,0 +1,24 @@
+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}}
-
+
{{> components/products/carousel products=product.related_products columns=6 list="Related Products"}}
+
{{/if}}
diff --git a/templates/pages/product.html b/templates/pages/product.html
index 24fa556748..a6d704262d 100644
--- a/templates/pages/product.html
+++ b/templates/pages/product.html
@@ -10,6 +10,9 @@
limit: {{theme_settings.productpage_similar_by_views_count}}
---
{{inject 'productId' product.id}}
+{{inject 'themeSettings' theme_settings}}
+{{inject 'settings' settings}}
+{{inject 'customerId' customer.id}}
{{#partial "page"}}