-
Notifications
You must be signed in to change notification settings - Fork 606
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
16 changed files
with
617 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -11,3 +11,4 @@ vendor/bundle | |
*.js.map | ||
*.zip | ||
.idea/ | ||
.history |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}`, | ||
}); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
142
assets/js/theme/product/recommendations/recommendations-carousel.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
Oops, something went wrong.