Skip to content

Commit 35baa3c

Browse files
committed
Product recommendations showcase
1 parent 854cdfc commit 35baa3c

16 files changed

Lines changed: 617 additions & 2 deletions

File tree

.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: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
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. Data attributes (`data-token-url="add-to-cart"` or `data-token-url="product-detail-page"`)
19+
are added to anchor elements in `templates/components/products/card.html`
20+
in order to be able to select elements in runtime and add static/recommendation token to urls.
21+
This is used by backend to recognize requests and calculate click-through rate during recommendation and default flows.
22+
23+
3. Some data is injected inside `templates/pages/product.html` in order to be accessible in js context
24+
inside `/assets/js/theme/product.js`.
25+
26+
4. `/assets/js/theme/product.js` is tweaked to invoke recommendations flow inside `onReady()` method.
27+
28+
### **Algorithm**
29+
30+
1. User goes to product detail view and browser sends request for a product page.
31+
`/templates/pages/product.html` is rendered server-side with some related products markup inside.
32+
In addition, with the response visitor group cookie `bc_rec_ab` is sent.
33+
34+
2. Entry point of recommendations flow: `/assets/js/theme/product.js: 59`.
35+
Cookie value with key`bc_rec_ab` is read.
36+
37+
2a. If the value corresponds to "Control group" (0) then "static token" assigned to
38+
all "Add To Cart" or "Detailed Product View" links inside each related product card.
39+
The flow is finished.
40+
`/assets/js/theme/product/recommendations/recommendations.js: 159`
41+
42+
2b. If the value is equal to "Treatment Group" (1) then execution proceeds to next step.
43+
`/assets/js/theme/product/recommendations/recommendations.js: 116`
44+
45+
3. Spinner is laid over currently rendered related products.
46+
`/assets/js/theme/product/recommendations/recommendations.js: 124`
47+
48+
4. Http request to cloud function is made (`/assets/js/theme/product/recommendations/recommendations.js: 23`).
49+
Response should contain recommendation token along with product ids generated by Recommendation AI.
50+
Host of the cloud function is located at `/assets/js/theme/product/recommendations/constants.js`.
51+
52+
Please, modify `CLOUD_FUNCTION_URL` constant to match an url of your deployed cloud function instance.
53+
Then the theme should be rebuilt by Stencil and uploaded to the store.
54+
Also, please, don't forget to setup `Access-Control-Allow-Headers` header which allows your frontend's
55+
domain make cross-origin HTTP requests to cloud function.
56+
57+
5. If request is successful and product ids are received,
58+
another GraphQL request is made to the backend in order to get product details (name, image, price, etc.).
59+
`/assets/js/theme/product/recommendations/recommendations.js: 58`
60+
61+
6. If GraphQL request is successful, markup for product cards elements is generated applying received data.
62+
Recommendation token (from p. 4) is attached to "Add To Cart" or "Detailed Product View" links
63+
in each generated product card.
64+
Finally, elements are inserted to DOM.
65+
`/assets/js/theme/product/recommendations/recommendations-carousel.js: 94`
66+
67+
7. Spinner is hidden and newly generated recommended products are shown.
68+
In case of error at steps 4-6, 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 = 'others-you-may-like-ctr-serving-config';
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+
}

0 commit comments

Comments
 (0)