Skip to content

Commit 3e18bc2

Browse files
committed
Product recommendations showcase
1 parent 6103fb6 commit 3e18bc2

File tree

13 files changed

+397
-2
lines changed

13 files changed

+397
-2
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ vendor/bundle
1111
*.js.map
1212
*.zip
1313
.idea/
14+
.history

assets/js/theme/product.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import ProductDetails from './common/product-details';
88
import videoGallery from './product/video-gallery';
99
import { classifyForm } from './common/utils/form-utils';
1010
import modalFactory from './global/modal';
11+
import applyRecommendations from './product/recommendations/recommendations';
1112

1213
export default class Product extends PageManager {
1314
constructor(context) {
@@ -16,6 +17,7 @@ export default class Product extends PageManager {
1617
this.$reviewLink = $('[data-reveal-id="modal-review-form"]');
1718
this.$bulkPricingLink = $('[data-reveal-id="modal-bulk-pricing"]');
1819
this.reviewModal = modalFactory('#modal-review-form')[0];
20+
this.$relatedProductsTabContent = $('#tab-related');
1921
}
2022

2123
onReady() {
@@ -59,6 +61,16 @@ export default class Product extends PageManager {
5961
});
6062

6163
this.productReviewHandler();
64+
65+
// Start product recommendations flow
66+
applyRecommendations(
67+
this.$relatedProductsTabContent,
68+
{
69+
productId: this.context.productId,
70+
themeSettings: this.context.themeSettings,
71+
storefrontAPIToken: this.context.settings.storefront_api.token,
72+
},
73+
);
6274
}
6375

6476
ariaDescribeReviewInputs($form) {
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
### **Description**
2+
3+
This Cornerstone theme modification introduces recommendations flow to UX.
4+
Below you could find description of consecutive steps happening in browser during the period user lands on product page
5+
and see products in "Related products" section.
6+
7+
### **Theme modifications**
8+
9+
JavaScript code for running recommendations flow resides in `/assets/js/theme/product/recommendations` folder.
10+
In order execute recommendations flow `applyRecommendations()` method from `recommendations.js` is invoked.
11+
12+
Changes made to the theme files except `/assets/js/theme/product/recommendations` folder:
13+
1. Overlay block added to `/templates/components/products/tabs.html` in order to show spinner while
14+
recommendations are being loaded.
15+
Also, "recommendations" class added to "Related products" tab element and css is slightly overridden
16+
for it in `/assets/scss/recommendations.scss`.
17+
18+
2. Some data is injected inside `templates/pages/product.html` in order to be accessible in js context
19+
inside `/assets/js/theme/product.js`.
20+
21+
3. `/assets/js/theme/product.js` is tweaked to invoke recommendations flow inside `onReady()` method.
22+
23+
### **Algorithm**
24+
25+
1. User goes to product detail view and browser sends request for a product page.
26+
`/templates/pages/product.html` is rendered server-side with some related products markup inside.
27+
28+
2. Entry point of recommendations flow: `/assets/js/theme/product.js: 66`.
29+
30+
3. Spinner is laid over currently rendered related products.
31+
`/assets/js/theme/product/recommendations/recommendations.js: 91`
32+
33+
4. Http request to BC GraphQL API is made (`/assets/js/theme/product/recommendations/recommendations.js: 93`).
34+
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.
35+
Service config ID is located in `/assets/js/theme/product/recommendations/constants.js`.
36+
37+
Please, modify `SERVICE_CONFIG_ID` constant to match a service config ID of your prepared one in Google Console Retail AI
38+
Then the theme should be rebuilt by Stencil and uploaded to the store.
39+
40+
5. If GraphQL request is successful, markup for product cards elements is generated applying received data.
41+
Recommendation token (from p. 4) is attached to "Add To Cart" or "Detailed Product View" links
42+
in each generated product card.
43+
Finally, elements are inserted to DOM.
44+
`/assets/js/theme/product/recommendations/recommendations-carousel.js: 100`
45+
46+
6. Spinner is hidden and newly generated recommended products are shown.
47+
In case of error at steps 4-5, spinner is hidden and initial related products are shown.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export const NUM_OF_PRODUCTS = 6;
2+
export const EVENT_TYPE = 'detail-page-view';
3+
export const SERVICE_CONFIG_ID = 'REPLACE_WITH_YOUR_SERVICE_CONFIG_ID';
4+
export const RECOM_TOKEN_PARAM = 'attributionToken';
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import request from './http';
2+
3+
export default function gql(query, variables, token) {
4+
return request('POST', '/graphql', JSON.stringify({ query, variables }), {
5+
'Content-Type': 'application/json',
6+
// eslint-disable-next-line quote-props
7+
Authorization: `Bearer ${token}`,
8+
});
9+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
export default function request(method, url, data, headers, options) {
2+
const xhr = new XMLHttpRequest();
3+
return new Promise((resolve, reject) => {
4+
xhr.onreadystatechange = function onReadyStateChange() {
5+
if (xhr.readyState !== 4) return;
6+
if (xhr.status >= 200 && xhr.status < 300) {
7+
resolve(xhr.response);
8+
} else {
9+
reject(new Error(xhr));
10+
}
11+
};
12+
xhr.withCredentials = (options && options.withCredentials) || false;
13+
xhr.responseType = (options && options.responseType) || 'json';
14+
xhr.open(method, url);
15+
16+
Object.keys(headers || {}).forEach((key) => {
17+
xhr.setRequestHeader(key, headers[key]);
18+
});
19+
20+
xhr.send(data);
21+
});
22+
}
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/* eslint-disable indent */
2+
import { addQueryParams } from './utils';
3+
import { RECOM_TOKEN_PARAM, NUM_OF_PRODUCTS } from './constants';
4+
5+
function renderPrice(node, themeSettings) {
6+
const { price, retailPrice } = node.prices || { price: {} };
7+
return `
8+
<div class="price-section price-section--withoutTax rrp-price--withoutTax"${!retailPrice ? ' style="display: none;"' : ''}>
9+
${themeSettings['pdp-retail-price-label']}
10+
<span data-product-rrp-price-without-tax class="price price--rrp">
11+
${retailPrice ? `${retailPrice.value} ${retailPrice.currencyCode}` : ''}
12+
</span>
13+
</div>
14+
<div class="price-section price-section--withoutTax">
15+
<span class="price-label">
16+
${themeSettings['pdp-price-label']}
17+
</span>
18+
<span data-product-price-without-tax class="price price--withoutTax">${price.value} ${price.currencyCode}</span>
19+
</div>
20+
`;
21+
}
22+
23+
function renderRestrictToLogin() {
24+
return '<p translate>Log in for pricing</p>';
25+
}
26+
27+
function renderCard(node, options) {
28+
const { themeSettings, attributionToken } = options;
29+
const categories = node.categories.edges.map(({ node: cNode }) => cNode.name).join(',');
30+
const productUrl = addQueryParams(node.path, { [RECOM_TOKEN_PARAM]: attributionToken });
31+
const addToCartUrl = addQueryParams(node.addToCartUrl, { [RECOM_TOKEN_PARAM]: attributionToken });
32+
33+
return `<div class="productCarousel-slide">
34+
<article
35+
class="card"
36+
data-entity-id="${node.entityId}"
37+
data-event-type=""
38+
data-name="${node.name}"
39+
data-product-brand="${node.brand && node.brand.name ? node.brand.name : ''}"
40+
data-product-price="${node.prices && node.prices.price.value}"
41+
data-product-category="${categories}"
42+
data-position=""
43+
>
44+
<figure class="card-figure">
45+
<a href="${productUrl}" data-event-type="product-click">
46+
<div class="card-img-container">
47+
${node.defaultImage ?
48+
`<img
49+
src="${node.defaultImage.urlOriginal}"
50+
alt="${node.name}"
51+
title="${node.name}"
52+
data-sizes="auto"
53+
srcset-bak=""
54+
class="card-image${themeSettings.lazyload_mode ? ' lazyload' : ''}"
55+
/>` : ''
56+
}
57+
58+
</div>
59+
</a>
60+
<figcaption class="card-figcaption">
61+
<div class="card-figcaption-body">
62+
${themeSettings.show_product_quick_view
63+
? `<a class="button button--small card-figcaption-button quickview"
64+
data-product-id="${node.entityId}
65+
data-event-type="product-click"
66+
>Quick view</a>`
67+
: ''}
68+
<a href="${addToCartUrl}" data-event-type="product-click" class="button button--small card-figcaption-button">Add to Cart</a>
69+
</div>
70+
</figcaption>
71+
</figure>
72+
<div class="card-body">
73+
${node.brand && node.brand.name ? `<p class="card-text" data-test-info-type="brandName">${node.brand.name}</p>` : ''}
74+
<h4 class="card-title">
75+
<a href="${productUrl}" data-event-type="product-click">${node.name}</a>
76+
</h4>
77+
<div class="card-text" data-test-info-type="price">
78+
${themeSettings.restrict_to_login ? renderRestrictToLogin() : renderPrice(node, themeSettings)}
79+
</div>
80+
</div>
81+
</article>
82+
</div>`;
83+
}
84+
85+
function createFallbackContainer(carousel) {
86+
const container = $('[itemscope] > .tabs-contents');
87+
const tabs = $('[itemscope] > .tabs');
88+
tabs.html(`
89+
<li class="tab is-active" role="presentational">
90+
<a class="tab-title" href="#tab-related" role="tab" tabindex="0" aria-selected="true" controls="tab-related">Related products</a>
91+
</li>
92+
`);
93+
container.html(`
94+
<div role="tabpanel" aria-hidden="false" class="tab-content has-jsContent is-active recommendations" id="tab-related">
95+
${carousel}
96+
</div>
97+
`);
98+
}
99+
100+
export default function injectRecommendations(products, el, options) {
101+
const cards = products
102+
.slice(0, NUM_OF_PRODUCTS)
103+
.map((product) => renderCard(product, options))
104+
.join('');
105+
106+
const carousel = `
107+
<section
108+
class="productCarousel"
109+
data-list-name="Recommended Products"
110+
data-slick='{
111+
"dots": true,
112+
"infinite": false,
113+
"mobileFirst": true,
114+
"slidesToShow": 2,
115+
"slidesToScroll": 2,
116+
"responsive": [
117+
{
118+
"breakpoint": 800,
119+
"settings": {
120+
"slidesToShow": ${NUM_OF_PRODUCTS},
121+
"slidesToScroll": 3
122+
}
123+
},
124+
{
125+
"breakpoint": 550,
126+
"settings": {
127+
"slidesToShow": 3,
128+
"slidesToScroll": 3
129+
}
130+
}
131+
]
132+
}'
133+
>
134+
${cards}
135+
</section>`;
136+
// eslint-disable-next-line no-param-reassign
137+
if (!el.get(0)) {
138+
createFallbackContainer(carousel);
139+
} else {
140+
el.html(carousel);
141+
}
142+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import gql from './graphql';
2+
import { EVENT_TYPE, NUM_OF_PRODUCTS, SERVICE_CONFIG_ID } from './constants';
3+
import injectRecommendations from './recommendations-carousel';
4+
import { showOverlay, hideOverlay, getSizeFromThemeSettings } from './utils';
5+
6+
/*
7+
* Invokes graphql query
8+
* @param {string} id - product id
9+
* @param {string} storefrontAPIToken - token from settings
10+
* @param {string} imageSize - e.g. '500x569'
11+
* @param {number} pageSize - number of products to be fetched
12+
* returns {Object}
13+
* */
14+
function getRecommendations(id, serviceConfigId, storefrontAPIToken, imageSize, pageSize, validateOnly = false) {
15+
return gql(
16+
`query ProductRecommendations($id: Int!, $includeTax: Boolean, $eventType: String!, $pageSize: Int!, $serviceConfigId: String!, $validateOnly: Boolean!) {
17+
site {
18+
apiExtensions {
19+
googleRetailApiPrediction(
20+
pageSize: $pageSize
21+
userEvent: {
22+
eventType: $eventType,
23+
productDetails: [{ entityId: $id, count: 1 }]
24+
}
25+
servingConfigId: $serviceConfigId
26+
validateOnly: $validateOnly
27+
) {
28+
attributionToken
29+
results {
30+
name
31+
entityId
32+
path
33+
brand {
34+
name
35+
}
36+
prices(includeTax:$includeTax) {
37+
price {
38+
value
39+
currencyCode
40+
}
41+
salePrice {
42+
value
43+
currencyCode
44+
}
45+
retailPrice {
46+
value
47+
currencyCode
48+
}
49+
}
50+
categories {
51+
edges {
52+
node {
53+
name
54+
}
55+
}
56+
}
57+
defaultImage {
58+
urlOriginal
59+
}
60+
addToCartUrl
61+
availability
62+
}
63+
}
64+
}
65+
}
66+
}`,
67+
{
68+
id: Number(id), includeTax: false, eventType: EVENT_TYPE, pageSize, serviceConfigId, validateOnly,
69+
},
70+
storefrontAPIToken,
71+
);
72+
}
73+
74+
/*
75+
* Carries out a flow with recommendations:
76+
* 1. Queries qraphql endpoint for recommended products information
77+
* 2. Creates carousel with product cards in "Related products" section
78+
* @param {Element} el - parent DOM element which carousel with products will be attached to
79+
* @param {Object} options - productId, customerId, settings, themeSettings
80+
* returns {Promise<void>}
81+
* */
82+
export default function applyRecommendations(el, options) {
83+
const consentManager = window.consentManager;
84+
85+
// Do not load recommendations if user has opted out of advertising consent category
86+
if (consentManager && !consentManager.preferences.loadPreferences().customPreferences.advertising) return;
87+
88+
const { productId, themeSettings, storefrontAPIToken } = options;
89+
const imageSize = getSizeFromThemeSettings(themeSettings.productgallery_size);
90+
91+
showOverlay(el);
92+
93+
return getRecommendations(
94+
productId,
95+
SERVICE_CONFIG_ID,
96+
storefrontAPIToken,
97+
imageSize,
98+
NUM_OF_PRODUCTS,
99+
)
100+
.then((response) => {
101+
const { attributionToken, results: products } = response.data.site.apiExtensions.googleRetailApiPrediction;
102+
103+
injectRecommendations(products, el, {
104+
products,
105+
themeSettings,
106+
productId,
107+
attributionToken,
108+
});
109+
})
110+
.catch(err => {
111+
// eslint-disable-next-line no-console
112+
console.error('Error happened during recommendations load', err);
113+
})
114+
.then(() => hideOverlay(el));
115+
}

0 commit comments

Comments
 (0)