Skip to content

Commit

Permalink
Product recommendations showcase
Browse files Browse the repository at this point in the history
  • Loading branch information
solofeed committed Jun 12, 2024
1 parent 6103fb6 commit 3e18bc2
Show file tree
Hide file tree
Showing 13 changed files with 397 additions and 2 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ vendor/bundle
*.js.map
*.zip
.idea/
.history
12 changes: 12 additions & 0 deletions assets/js/theme/product.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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() {
Expand Down Expand Up @@ -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) {
Expand Down
47 changes: 47 additions & 0 deletions assets/js/theme/product/recommendations/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
### **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. Some data is injected inside `templates/pages/product.html` in order to be accessible in js context
inside `/assets/js/theme/product.js`.

3. `/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.

2. Entry point of recommendations flow: `/assets/js/theme/product.js: 66`.

3. Spinner is laid over currently rendered related products.
`/assets/js/theme/product/recommendations/recommendations.js: 91`

4. Http request to BC GraphQL API is made (`/assets/js/theme/product/recommendations/recommendations.js: 93`).
Response should contain recommendation token along with products generated by Google Retail AI. Products already hydrated, so you don't need to fetch them separatelly.
Service config ID is located in `/assets/js/theme/product/recommendations/constants.js`.

Please, modify `SERVICE_CONFIG_ID` constant to match a service config ID of your prepared one in Google Console Retail AI
Then the theme should be rebuilt by Stencil and uploaded to the store.

5. 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: 100`

6. Spinner is hidden and newly generated recommended products are shown.
In case of error at steps 4-5, spinner is hidden and initial related products are shown.
4 changes: 4 additions & 0 deletions assets/js/theme/product/recommendations/constants.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const NUM_OF_PRODUCTS = 6;
export const EVENT_TYPE = 'detail-page-view';
export const SERVICE_CONFIG_ID = 'REPLACE_WITH_YOUR_SERVICE_CONFIG_ID';
export const RECOM_TOKEN_PARAM = 'attributionToken';
9 changes: 9 additions & 0 deletions assets/js/theme/product/recommendations/graphql.js
Original file line number Diff line number Diff line change
@@ -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}`,
});
}
22 changes: 22 additions & 0 deletions assets/js/theme/product/recommendations/http.js
Original file line number Diff line number Diff line change
@@ -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);
});
}
142 changes: 142 additions & 0 deletions assets/js/theme/product/recommendations/recommendations-carousel.js
Original file line number Diff line number Diff line change
@@ -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 `
<div class="price-section price-section--withoutTax rrp-price--withoutTax"${!retailPrice ? ' style="display: none;"' : ''}>
${themeSettings['pdp-retail-price-label']}
<span data-product-rrp-price-without-tax class="price price--rrp">
${retailPrice ? `${retailPrice.value} ${retailPrice.currencyCode}` : ''}
</span>
</div>
<div class="price-section price-section--withoutTax">
<span class="price-label">
${themeSettings['pdp-price-label']}
</span>
<span data-product-price-without-tax class="price price--withoutTax">${price.value} ${price.currencyCode}</span>
</div>
`;
}

function renderRestrictToLogin() {
return '<p translate>Log in for pricing</p>';
}

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 `<div class="productCarousel-slide">
<article
class="card"
data-entity-id="${node.entityId}"
data-event-type=""
data-name="${node.name}"
data-product-brand="${node.brand && node.brand.name ? node.brand.name : ''}"
data-product-price="${node.prices && node.prices.price.value}"
data-product-category="${categories}"
data-position=""
>
<figure class="card-figure">
<a href="${productUrl}" data-event-type="product-click">
<div class="card-img-container">
${node.defaultImage ?
`<img
src="${node.defaultImage.urlOriginal}"
alt="${node.name}"
title="${node.name}"
data-sizes="auto"
srcset-bak=""
class="card-image${themeSettings.lazyload_mode ? ' lazyload' : ''}"
/>` : ''
}
</div>
</a>
<figcaption class="card-figcaption">
<div class="card-figcaption-body">
${themeSettings.show_product_quick_view
? `<a class="button button--small card-figcaption-button quickview"
data-product-id="${node.entityId}
data-event-type="product-click"
>Quick view</a>`
: ''}
<a href="${addToCartUrl}" data-event-type="product-click" class="button button--small card-figcaption-button">Add to Cart</a>
</div>
</figcaption>
</figure>
<div class="card-body">
${node.brand && node.brand.name ? `<p class="card-text" data-test-info-type="brandName">${node.brand.name}</p>` : ''}
<h4 class="card-title">
<a href="${productUrl}" data-event-type="product-click">${node.name}</a>
</h4>
<div class="card-text" data-test-info-type="price">
${themeSettings.restrict_to_login ? renderRestrictToLogin() : renderPrice(node, themeSettings)}
</div>
</div>
</article>
</div>`;
}

function createFallbackContainer(carousel) {
const container = $('[itemscope] > .tabs-contents');
const tabs = $('[itemscope] > .tabs');
tabs.html(`
<li class="tab is-active" role="presentational">
<a class="tab-title" href="#tab-related" role="tab" tabindex="0" aria-selected="true" controls="tab-related">Related products</a>
</li>
`);
container.html(`
<div role="tabpanel" aria-hidden="false" class="tab-content has-jsContent is-active recommendations" id="tab-related">
${carousel}
</div>
`);
}

export default function injectRecommendations(products, el, options) {
const cards = products
.slice(0, NUM_OF_PRODUCTS)
.map((product) => renderCard(product, options))
.join('');

const carousel = `
<section
class="productCarousel"
data-list-name="Recommended Products"
data-slick='{
"dots": true,
"infinite": false,
"mobileFirst": true,
"slidesToShow": 2,
"slidesToScroll": 2,
"responsive": [
{
"breakpoint": 800,
"settings": {
"slidesToShow": ${NUM_OF_PRODUCTS},
"slidesToScroll": 3
}
},
{
"breakpoint": 550,
"settings": {
"slidesToShow": 3,
"slidesToScroll": 3
}
}
]
}'
>
${cards}
</section>`;
// eslint-disable-next-line no-param-reassign
if (!el.get(0)) {
createFallbackContainer(carousel);
} else {
el.html(carousel);
}
}
115 changes: 115 additions & 0 deletions assets/js/theme/product/recommendations/recommendations.js
Original file line number Diff line number Diff line change
@@ -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<void>}
* */
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));
}
Loading

0 comments on commit 3e18bc2

Please sign in to comment.