Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add recommendations block with ACDL #11

Merged
merged 25 commits into from
Mar 15, 2024
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
bc902f8
Add recommendations block with ACDL2
herzog31 Nov 7, 2023
9f0553b
Fix array issue for product views
herzog31 Nov 7, 2023
c0223e8
Fix enrichment position
herzog31 Nov 7, 2023
1cf384c
Update recommendations context
herzog31 Nov 9, 2023
abd9fdc
Add schema validation for events
herzog31 Nov 9, 2023
b5f507a
Merge branch 'main' of github.com:hlxsites/aem-boilerplate-commerce i…
herzog31 Nov 20, 2023
769aa8c
Merge branch 'main' of github.com:hlxsites/aem-boilerplate-commerce i…
herzog31 Dec 6, 2023
c101adc
Merge branch 'main' of github.com:hlxsites/aem-boilerplate-commerce i…
herzog31 Jan 8, 2024
ae07731
Fix ACDL2 and product view history bugs
herzog31 Jan 8, 2024
774720f
Merge branch 'main' of github.com:hlxsites/aem-boilerplate-commerce i…
herzog31 Jan 8, 2024
f059e56
Fix CLS
herzog31 Jan 8, 2024
a2ca757
Merge branch 'main' of github.com:hlxsites/aem-boilerplate-commerce i…
herzog31 Jan 15, 2024
c6f2fb1
Use base design system
herzog31 Jan 15, 2024
671c4b4
Merge branch 'main' of github.com:hlxsites/aem-boilerplate-commerce i…
herzog31 Mar 5, 2024
a52c814
Update styles
herzog31 Mar 5, 2024
ec1f23b
Replace ACDL2 with mainline ACDL
herzog31 Mar 6, 2024
ac5c938
Add viewHistory and cartSkus
herzog31 Mar 6, 2024
d0554f4
Add storefront events to recommendations block
herzog31 Mar 7, 2024
9b97591
Optimize recommendations context
herzog31 Mar 7, 2024
5b50032
Merge branch 'main' of github.com:hlxsites/aem-boilerplate-commerce i…
herzog31 Mar 7, 2024
b47e843
Add PLP widget version that uses ACDL
herzog31 Mar 11, 2024
826d2ca
Add categoryContext to PLP
herzog31 Mar 14, 2024
3c33ebe
Remove optional category url path from PLP
herzog31 Mar 14, 2024
793494f
Add categoryIds filter
herzog31 Mar 14, 2024
3686f01
Update autocomplete widget
herzog31 Mar 15, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .eslintignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
helix-importer-ui
scripts/preact.js
scripts/htm.js
scripts/acdl
tools/picker
scripts/widgets
scripts/widgets
12 changes: 10 additions & 2 deletions blocks/product-details/product-details.js
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@
};

onAddToWishlist = async () => {
console.debug('onAddToWishlist', this.state.product.sku);

Check warning on line 116 in blocks/product-details/product-details.js

View workflow job for this annotation

GitHub Actions / build

Unexpected console statement
};

onQuantityChanged = (quantity) => {
Expand Down Expand Up @@ -148,8 +148,16 @@
if (!loading && product) {
setJsonLdProduct(product);
document.title = product.name;
// TODO: productId not exposed by catalog service as number
window.adobeDataLayer.push({ productContext: { productId: 0, ...product } }, { event: 'product-page-view' });
window.adobeDataLayer.push((dl) => {
dl.push({
productContext: {
productId: parseInt(product.externalId, 10) || 0,
...product,
},
});
// TODO: Remove eventInfo once collector is updated
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you already know what "updated" means, cool, but maybe you could add a specific version or ticket here.

dl.push({ event: 'product-page-view', eventInfo: { ...dl.getState() } });
});
}
}

Expand Down
5 changes: 4 additions & 1 deletion blocks/product-list-page-custom/ProductList.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,10 @@ class ProductCard extends Component {
}

onProductClick(product) {
window.adobeDataLayer.push({ event: 'search-product-click', eventInfo: { searchUnitId: 'searchUnitId', sku: product.sku } });
window.adobeDataLayer.push((dl) => {
// TODO: Remove eventInfo once collector is updated
dl.push({ event: 'search-product-click', eventInfo: { ...dl.getState(), searchUnitId: 'searchUnitId', sku: product.sku } });
});
}

render({ product, loading, index }) {
Expand Down
30 changes: 17 additions & 13 deletions blocks/product-list-page-custom/product-list-page-custom.js
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,9 @@
} else {
searchInputContext.units[index] = unit;
}
dl.push({ searchInputContext }, { event: 'search-request-sent', eventInfo: { searchUnitId } });
dl.push({ searchInputContext });
// TODO: Remove eventInfo once collector is updated
dl.push({ event: 'search-request-sent', eventInfo: { ...dl.getState(), searchUnitId } });
});

const response = await performCatalogServiceQuery(productSearchQuery(state.type === 'category'), variables);
Expand All @@ -164,7 +166,7 @@
facets: response.productSearch.facets.filter((facet) => facet.attribute !== 'categories'),
};
} catch (e) {
console.error('Error loading products', e);

Check warning on line 169 in blocks/product-list-page-custom/product-list-page-custom.js

View workflow job for this annotation

GitHub Actions / build

Unexpected console statement
return {
pages: 1,
products: {
Expand Down Expand Up @@ -389,22 +391,24 @@
} else {
searchResultsContext.units[index] = searchResultUnit;
}
dl.push({ searchResultsContext }, { event: 'search-response-received', eventInfo: { searchUnitId } });
dl.push({ searchResultsContext });
// TODO: Remove eventInfo once collector is updated
dl.push({ event: 'search-response-received', eventInfo: { ...dl.getState(), searchUnitId } });
if (this.props.type === 'search') {
dl.push({ event: 'search-results-view', eventInfo: { searchUnitId } });
// TODO: Remove eventInfo once collector is updated
dl.push({ event: 'search-results-view', eventInfo: { ...dl.getState(), searchUnitId } });
} else {
dl.push(
{ event: 'category-results-view', eventInfo: { searchUnitId } },
{
categoryContext: {
name: this.state.category.name,
urlKey: this.state.category.urlKey,
urlPath: this.state.category.urlPath,
},
dl.push({
categoryContext: {
name: this.state.category.name,
urlKey: this.state.category.urlKey,
urlPath: this.state.category.urlPath,
},
);
});
// TODO: Remove eventInfo once collector is updated
dl.push({ event: 'category-results-view', eventInfo: { ...dl.getState(), searchUnitId } });
}
dl.push({ event: 'page-view' });
dl.push({ event: 'page-view', eventInfo: { ...dl.getState() } });
});
}
};
Expand Down
80 changes: 80 additions & 0 deletions blocks/product-recommendations/product-recommendations.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
main .section>div.product-recommendations-wrapper {
max-width: 100%;
padding: 0;
text-align: left;
margin: 0 0 5rem;
}

.product-recommendations {
overflow: hidden;
min-height: 512px;
}

.product-recommendations .scrollable {
overflow-x: scroll;
scroll-snap-type: x mandatory;
padding-bottom: 1rem;
}

.product-recommendations .product-grid {
display: inline-flex;
flex-wrap: nowrap;
gap: 2rem;
margin: 0;
}

.product-recommendations .product-grid-item img {
width: 100%;
}

.product-recommendations .product-grid .product-grid-item a {
text-decoration: none;
}

.product-recommendations .product-grid .product-grid-item a:hover,
.product-recommendations .product-grid .product-grid-item a:focus {
text-decoration: underline;
}

.product-recommendations .product-grid .product-grid-item span {
overflow: hidden;
box-sizing: border-box;
margin: 0;
padding: .5rem 1rem 0 0;
display: inline-block;
}

.product-recommendations .product-grid picture {
background: none;
display: block;
width: 300px;
aspect-ratio: 1 / 1.25;
}

.product-recommendations .product-grid img {
display: inline-block;
vertical-align: middle;
width: 100%;
object-fit: contain;
background: none;
}

.product-recommendations .product-grid .placeholder {
background-color: var(--color-neutral-500);
scroll-snap-align: start;
}

.product-recommendations .product-grid .placeholder img {
display: none;
}

.product-recommendations .product-grid-item {
margin: 0;
scroll-snap-align: start;
}

@media (width >= 900px) {
.product-recommendations {
min-height: 534px;
}
}
214 changes: 214 additions & 0 deletions blocks/product-recommendations/product-recommendations.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
/* eslint-disable no-underscore-dangle */
import { performCatalogServiceQuery } from '../../scripts/commerce.js';
import { getConfigValue } from '../../scripts/configs.js';

const recommendationsQuery = `query GetRecommendations(
$pageType: PageType!
$category: String
$currentSku: String
$cartSkus: [String]
$userPurchaseHistory: [PurchaseHistory]
$userViewHistory: [ViewHistory]
) {
recommendations(
cartSkus: $cartSkus
category: $category
currentSku: $currentSku
pageType: $pageType
userPurchaseHistory: $userPurchaseHistory
userViewHistory: $userViewHistory
) {
results {
displayOrder
pageType
productsView {
name
sku
url
images {
url
}
externalId
__typename
}
storefrontLabel
totalProducts
typeId
unitId
unitName
}
totalResults
}
}`;

let recommendationsPromise;

function renderPlaceholder(block) {
block.innerHTML = `<h2></h2>
<div class="scrollable">
<div class="product-grid">
${[...Array(5)].map(() => `
<div class="placeholder">
<picture><img width="300" height="375" src="" /></picture>
</div>
`).join('')}
</div>
</div>`;
}

function renderItem(unitId, product) {
const urlKey = product.url.split('/').pop().replace('.html', '');
const image = product.images[0]?.url;

const clickHandler = () => {
window.adobeDataLayer.push((dl) => {
dl.push({ event: 'recs-item-click', eventInfo: { ...dl.getState(), unitId, productId: parseInt(product.externalId, 10) || 0 } });
});
};

const item = document.createRange().createContextualFragment(`<div class="product-grid-item">
<a href="/products/${urlKey}/${product.sku.toLowerCase()}">
<picture>
<source type="image/webp" srcset="${image}?width=300&format=webply&optimize=medium" />
<img loading="lazy" alt="${product.name}" width="300" height="375" src="${image}?width=300&format=jpg&optimize=medium" />
</picture>
<span>${product.name}</span>
</a>
</div>`);
item.querySelector('a').addEventListener('click', clickHandler);

return item;
}

function renderItems(block, recommendations) {
// Render only first recommendation
const [recommendation] = recommendations.results;
if (!recommendation) {
// Hide block content if no recommendations are available
block.textContent = '';
return;
}

window.adobeDataLayer.push((dl) => {
dl.push({ event: 'recs-unit-impression-render', eventInfo: { ...dl.getState(), unitId: recommendation.unitId } });
});

// Title
block.querySelector('h2').textContent = recommendation.storefrontLabel;

// Grid
const grid = block.querySelector('.product-grid');
grid.innerHTML = '';
const { productsView } = recommendation;
productsView.forEach((product) => {
grid.appendChild(renderItem(recommendation.unitId, product));
});

const inViewObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
window.adobeDataLayer.push((dl) => {
dl.push({ event: 'recs-unit-view', eventInfo: { ...dl.getState(), unitId: recommendation.unitId } });
});
inViewObserver.disconnect();
}
});
});
inViewObserver.observe(block);
}

const mapUnit = (unit) => ({
...unit,
unitType: 'primary',
searchTime: 0,
primaryProducts: unit.totalProducts,
backupProducts: 0,
products: unit.productsView.map((product, index) => ({
...product,
rank: index,
score: 0,
productId: parseInt(product.externalId, 10) || 0,
type: '?',
queryType: product.__typename,
})),
});

async function loadRecommendation(block, context) {
// Only proceed if all required data is available
if (!context.pageType
|| (context.pageType === 'Product' && !context.currentSku)
|| (context.pageType === 'Category' && !context.category)
|| (context.pageType === 'Cart' && !context.cartSkus)) {
return;
}

if (recommendationsPromise) {
return;
}

const storeViewCode = await getConfigValue('commerce-store-view-code');
// Get product view history
try {
const viewHistory = window.localStorage.getItem(`${storeViewCode}:productViewHistory`) || '[]';
context.userViewHistory = JSON.parse(viewHistory);
} catch (e) {
window.localStorage.removeItem('productViewHistory');
console.error('Error parsing product view history', e);

Check warning on line 156 in blocks/product-recommendations/product-recommendations.js

View workflow job for this annotation

GitHub Actions / build

Unexpected console statement
}

// Get purchase history
try {
const purchaseHistory = window.localStorage.getItem(`${storeViewCode}:purchaseHistory`) || '[]';
context.userPurchaseHistory = JSON.parse(purchaseHistory);
} catch (e) {
window.localStorage.removeItem('purchaseHistory');
console.error('Error parsing purchase history', e);

Check warning on line 165 in blocks/product-recommendations/product-recommendations.js

View workflow job for this annotation

GitHub Actions / build

Unexpected console statement
}

window.adobeDataLayer.push((dl) => {
dl.push({ event: 'recs-api-request-sent', eventInfo: { ...dl.getState() } });
});

recommendationsPromise = performCatalogServiceQuery(recommendationsQuery, context);
const { recommendations } = await recommendationsPromise;

window.adobeDataLayer.push((dl) => {
dl.push({ recommendationsContext: { units: recommendations.results.map(mapUnit) } });
dl.push({ event: 'recs-api-response-received', eventInfo: { ...dl.getState() } });
});

renderItems(block, recommendations);
}

export default async function decorate(block) {
renderPlaceholder(block);

const context = {};

function handleProductChanges({ productContext }) {
context.currentSku = productContext.sku;
loadRecommendation(block, context);
}

function handleCategoryChanges({ categoryContext }) {
context.category = categoryContext.name;
loadRecommendation(block, context);
}

function handlePageTypeChanges({ pageContext }) {
context.pageType = pageContext.pageType;
loadRecommendation(block, context);
}

function handleCartChanges({ shoppingCartContext }) {
context.cartSkus = shoppingCartContext.items.map(({ product }) => product.sku);
loadRecommendation(block, context);
}

window.adobeDataLayer.push((dl) => {
dl.addEventListener('adobeDataLayer:change', handlePageTypeChanges, { path: 'pageContext' });
dl.addEventListener('adobeDataLayer:change', handleProductChanges, { path: 'productContext' });
dl.addEventListener('adobeDataLayer:change', handleCategoryChanges, { path: 'categoryContext' });
dl.addEventListener('adobeDataLayer:change', handleCartChanges, { path: 'shoppingCartContext' });
});
}
2 changes: 2 additions & 0 deletions scripts/acdl/adobe-client-data-layer.min.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions scripts/acdl/adobe-client-data-layer.min.js.map

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions scripts/acdl/ajv2020.min.js

Large diffs are not rendered by default.

Loading
Loading