-
Notifications
You must be signed in to change notification settings - Fork 615
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
13 changed files
with
397 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,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. |
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 = 'REPLACE_WITH_YOUR_SERVICE_CONFIG_ID'; | ||
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); | ||
} | ||
} |
115 changes: 115 additions & 0 deletions
115
assets/js/theme/product/recommendations/recommendations.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,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)); | ||
} |
Oops, something went wrong.