Skip to content

Commit

Permalink
Add product teaser block (#22)
Browse files Browse the repository at this point in the history
* Add product teaser block

* Update styling

* Add commerce events

* Update product teaser block in Commerce picker

* Fix product images
  • Loading branch information
herzog31 authored Jan 31, 2024
1 parent fc88fa3 commit 8b772ac
Show file tree
Hide file tree
Showing 11 changed files with 312 additions and 20 deletions.
5 changes: 1 addition & 4 deletions blocks/product-details/product-details.js
Original file line number Diff line number Diff line change
Expand Up @@ -108,10 +108,7 @@ class ProductDetailPage extends Component {
if (Object.keys(this.state.selection).length === (this.state.product.options?.length || 0)) {
const optionsUIDs = Object.values(this.state.selection).map((option) => option.id);
const { cartApi } = await import('../../scripts/minicart/api.js');
console.debug('onAddToCart', {
sku: this.state.product.sku, optionsUIDs, quantity: this.state.selectedQuantity ?? 1,
});
cartApi.addToCart(this.state.product.sku, optionsUIDs, this.state.selectedQuantity ?? 1);
cartApi.addToCart(this.state.product.sku, optionsUIDs, this.state.selectedQuantity ?? 1, 'product-detail');
}
};

Expand Down
89 changes: 89 additions & 0 deletions blocks/product-teaser/product-teaser.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
.product-teaser-wrapper {
padding: 20px 0;
}

.product-teaser {
display: flex;
gap: 40px;
flex-direction: column;
justify-content: center;
}

.product-teaser .image {
display: flex;
justify-content: center;
}

.product-teaser .image .placeholder {
width: 250px;
height: 250px;
background-color: var(--color-neutral-200);
}

.product-teaser .image picture>img {
display: block;
width: 250px;
height: 250px;
object-fit: contain;
}

.product-teaser .details {
display: flex;
flex-direction: column;
}

.product-teaser .actions {
display: flex;
gap: 20px;
margin-top: auto;
justify-content: center;
}

.product-teaser h1 {
min-height: 32px;
font: var(--type-headline-1-font);
letter-spacing: var(--type-headline-1-letter-spacing);
margin: 0 0 16px;
}

.product-teaser .price {
font: var(--type-headline-2-strong-font);
letter-spacing: var(--type-headline-2-strong-letter-spacing);
}

.product-teaser .price .price-regular {
font: var(--type-headline-2-default-font);
letter-spacing: var(--type-headline-2-default-letter-spacing);
color: var(--color-neutral-600);
text-decoration: line-through;
}

.product-teaser .price .price-range {
display: flex;
gap: 20px;
align-items: baseline;
}

.product-teaser .price .price-range .price-from {
font: var(--type-headline-2-strong-font);
letter-spacing: var(--type-headline-2-strong-letter-spacing);
}

.product-teaser .price .price-final {
font: var(--type-headline-2-strong-font);
letter-spacing: var(--type-headline-2-strong-letter-spacing);
}

@media (width >= 600px) {
.product-teaser {
flex-direction: row;
}

.product-teaser .details {
flex: 1;
}

.product-teaser .actions {
justify-content: flex-start;
}
}
151 changes: 151 additions & 0 deletions blocks/product-teaser/product-teaser.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { readBlockConfig } from '../../scripts/aem.js';
import { performCatalogServiceQuery, renderPrice } from '../../scripts/commerce.js';

const productTeaserQuery = `query productTeaser($sku: String!) {
products(skus: [$sku]) {
sku
urlKey
name
addToCartAllowed
__typename
images(roles: ["small_image"]) {
label
url
}
... on SimpleProductView {
price {
...priceFields
}
}
... on ComplexProductView {
priceRange {
minimum {
...priceFields
}
maximum {
...priceFields
}
}
}
}
}
fragment priceFields on ProductViewPrice {
regular {
amount {
currency
value
}
}
final {
amount {
currency
value
}
}
}`;

function renderPlaceholder(config, block) {
block.textContent = '';
block.appendChild(document.createRange().createContextualFragment(`
<div class="image">
<div class="placeholder"></div>
</div>
<div class="details">
<h1></h1>
<div class="price"></div>
<div class="actions">
${config['details-button'] ? '<a href="#" class="button primary disabled">Details</a>' : ''}
${config['cart-button'] ? '<button class="secondary" disabled>Add to Cart</button>' : ''}
</div>
</div>
`));
}

function renderImage(image, size = 250) {
const { url: imageUrl, label } = image;
const createUrlForWidth = (url, w, useWebply = true) => {
const newUrl = new URL(url, window.location);
if (useWebply) {
newUrl.searchParams.set('format', 'webply');
newUrl.searchParams.set('optimize', 'medium');
} else {
newUrl.searchParams.delete('format');
}
newUrl.searchParams.set('width', w);
newUrl.searchParams.delete('quality');
newUrl.searchParams.delete('dpr');
newUrl.searchParams.delete('bg-color');
return newUrl.toString();
};

const createUrlForDpi = (url, w, useWebply = true) => `${createUrlForWidth(url, w, useWebply)} 1x, ${createUrlForWidth(url, w * 2, useWebply)} 2x, ${createUrlForWidth(url, w * 3, useWebply)} 3x`;

const webpUrl = createUrlForDpi(imageUrl, size, true);
const jpgUrl = createUrlForDpi(imageUrl, size, false);

return document.createRange().createContextualFragment(`<picture>
<source srcset="${webpUrl}" />
<source srcset="${jpgUrl}" />
<img height="${size}" width="${size}" src="${createUrlForWidth(imageUrl, size, false)}" loading="eager" alt="${label}" />
</picture>
`);
}

function renderProduct(product, config, block) {
const {
name, urlKey, sku, price, priceRange, addToCartAllowed, __typename,
} = product;

const currency = price?.final?.amount?.currency || priceRange?.minimum?.final?.amount?.currency;
const priceFormatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
});

block.textContent = '';
const fragment = document.createRange().createContextualFragment(`
<div class="image">
</div>
<div class="details">
<h1>${name}</h1>
<div class="price">${renderPrice(product, priceFormatter.format)}</div>
<div class="actions">
${config['details-button'] ? `<a href="/products/${urlKey}/${sku}" class="button primary">Details</a>` : ''}
${config['cart-button'] && addToCartAllowed && __typename === 'SimpleProductView' ? '<button class="add-to-cart secondary">Add to Cart</button>' : ''}
</div>
</div>
`);

fragment.querySelector('.image').appendChild(renderImage(product.images[0], 250));

const addToCartButton = fragment.querySelector('.add-to-cart');
if (addToCartButton) {
addToCartButton.addEventListener('click', async () => {
const { cartApi } = await import('../../scripts/minicart/api.js');
// TODO: productId not exposed by catalog service as number
window.adobeDataLayer.push({ productContext: { productId: 0, ...product } });
cartApi.addToCart(product.sku, [], 1, 'product-teaser');
});
}

block.appendChild(fragment);
}

export default async function decorate(block) {
const config = readBlockConfig(block);
config['details-button'] = !!(config['details-button'] || config['details-button'] === 'true');
config['cart-button'] = !!(config['cart-button'] || config['cart-button'] === 'true');

renderPlaceholder(config, block);

const { products } = await performCatalogServiceQuery(productTeaserQuery, {
sku: config.sku,
});
if (!products || !products.length > 0 || !products[0].sku) {
return;
}
const [product] = products;
product.images = product.images.map((image) => ({ ...image, url: image.url.replace(/^https?:/, '') }));

renderProduct(product, config, block);
}
6 changes: 3 additions & 3 deletions scripts/commerce.js
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@ export async function performMonolithGraphQLQuery(query, variables, GET = true,
return response.json();
}

export function renderPrice(product, format, html, Fragment) {
export function renderPrice(product, format, html = (strings, ...values) => strings.reduce((result, string, i) => result + string + (values[i] || ''), ''), Fragment = null) {
// Simple product
if (product.price) {
const { regular, final } = product.price;
Expand All @@ -290,14 +290,14 @@ export function renderPrice(product, format, html, Fragment) {
if (finalMin.amount.value !== finalMax.amount.value) {
return html`
<div class="price-range">
<span class="price-from">${format(finalMin.amount.value)}</span><span class="price-from">${format(finalMax.amount.value)}</span>
${finalMin.amount.value !== regularMin.amount.value ? html`<span class="price-regular">${format(regularMin.amount.value)}</span>` : ''}
<span class="price-from">${format(finalMin.amount.value)} - ${format(finalMax.amount.value)}</span>
</div>`;
}

if (finalMin.amount.value !== regularMin.amount.value) {
return html`<${Fragment}>
<span class="price-final">${format(finalMin.amount.value)}</span> <span class="price-regular">${format(regularMin.amount.value)}</span>
<span class="price-final">${format(finalMin.amount.value)} - ${format(regularMin.amount.value)}</span>
</${Fragment}>`;
}

Expand Down
4 changes: 2 additions & 2 deletions scripts/minicart/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -111,14 +111,14 @@ class Store {
export const store = new Store();

export const cartApi = {
addToCart: async (sku, options, quantity) => {
addToCart: async (sku, options, quantity, source = 'product-detail') => {
const { addToCart, createCart } = await import('./cart.js');
const { showCart } = await import('./Minicart.js');
if (!store.getCartId()) {
console.debug('Cannot add item to cart, need to create a new cart first.');

Check warning on line 118 in scripts/minicart/api.js

View workflow job for this annotation

GitHub Actions / build

Unexpected console statement
await createCart();
}
await addToCart(sku, options, quantity);
await addToCart(sku, options, quantity, source);
showCart();
},
toggleCart: async () => {
Expand Down
48 changes: 47 additions & 1 deletion scripts/minicart/cart.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,43 @@ export {
};

/* Methods */
function mapCartItem(item) {
return {
id: item.uid,
prices: {
price: {
value: item.prices?.price?.value,
currency: item.prices?.price?.currency,
},
},
product: {
// TODO: productId not exposed by core GraphQL as number
productId: 0,
name: item.product?.name,
sku: item.product?.sku,
},
configurableOptions: item.configurable_options?.map((option) => ({
optionLabel: option?.option_label,
valueLabel: option?.value_label,
})),
quantity: item.quantity,
};
}

function mapCartToMSE(cart, source) {
return {
id: cart.id,
items: cart.items.map(mapCartItem),
prices: {
subtotalExcludingTax: {
value: cart.prices?.subtotal_excluding_tax?.value,
currency: cart.prices?.subtotal_excluding_tax?.currency,
},
},
totalQuantity: cart.total_quantity,
source,
};
}

const handleCartErrors = (errors) => {
if (!errors) {
Expand Down Expand Up @@ -173,7 +210,7 @@ export async function createCart() {
}
}

export async function addToCart(sku, options, quantity) {
export async function addToCart(sku, options, quantity, source) {
const done = waitForCart();
try {
const variables = {
Expand All @@ -200,6 +237,15 @@ export async function addToCart(sku, options, quantity) {

cart.items = cart.items.filter((item) => item);
store.setCart(cart);
const mseCart = mapCartToMSE(cart, source);

// TODO: Find exact item by comparing options UIDs
const mseChangedItems = cart.items.filter((item) => item.product.sku === sku).map(mapCartItem);
window.adobeDataLayer.push(
{ shoppingCartContext: mseCart },
{ changedProductsContext: { items: mseChangedItems } },
{ event: 'add-to-cart' },
);

console.debug('Added items to cart', variables, cart);
} catch (err) {
Expand Down
1 change: 1 addition & 0 deletions styles/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,7 @@ button:focus {
cursor: pointer;
}

a.button.disabled,
button:disabled,
button:disabled:hover {
background-color: var(--color-neutral-300);
Expand Down
Loading

0 comments on commit 8b772ac

Please sign in to comment.