Skip to content

Commit

Permalink
Product recommendations showcase
Browse files Browse the repository at this point in the history
  • Loading branch information
solofeed committed May 23, 2024
1 parent 854cdfc commit 35baa3c
Show file tree
Hide file tree
Showing 16 changed files with 617 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
68 changes: 68 additions & 0 deletions assets/js/theme/product/recommendations/README.md
Original file line number Diff line number Diff line change
@@ -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.
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 = 'others-you-may-like-ctr-serving-config';
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);
}
}
Loading

0 comments on commit 35baa3c

Please sign in to comment.