diff --git a/blocks/product-details-custom/product-details-custom.js b/blocks/product-details-custom/product-details-custom.js index 047ba596f4..20dd4ce52d 100644 --- a/blocks/product-details-custom/product-details-custom.js +++ b/blocks/product-details-custom/product-details-custom.js @@ -13,7 +13,7 @@ import { performCatalogServiceQuery, refineProductQuery, setJsonLd, - loadErrorPage, + loadErrorPage, variantsQuery, } from '../../scripts/commerce.js'; import { readBlockConfig } from '../../scripts/aem.js'; @@ -40,18 +40,17 @@ async function setJsonLdProduct(product) { const amount = priceRange?.minimum?.final?.amount || price?.final?.amount; const brand = attributes.find((attr) => attr.name === 'brand'); - setJsonLd({ + // get variants + const { variants } = (await performCatalogServiceQuery(variantsQuery, { sku }))?.variants + || { variants: [] }; + + const ldJson = { '@context': 'http://schema.org', '@type': 'Product', name, description, image: images[0]?.url, - offers: [{ - '@type': 'http://schema.org/Offer', - price: amount?.value, - priceCurrency: amount?.currency, - availability: inStock ? 'http://schema.org/InStock' : 'http://schema.org/OutOfStock', - }], + offers: [], productID: sku, brand: { '@type': 'Brand', @@ -60,7 +59,28 @@ async function setJsonLdProduct(product) { url: new URL(`/products/${urlKey}/${sku}`, window.location), sku, '@id': new URL(`/products/${urlKey}/${sku}`, window.location), - }, 'product'); + }; + + if (variants.length > 1) { + ldJson.offers.push(...variants.map((variant) => ({ + '@type': 'Offer', + name: variant.product.name, + image: variant.product.images[0]?.url, + price: variant.product.price.final.amount.value, + priceCurrency: variant.product.price.final.amount.currency, + availability: variant.product.inStock ? 'http://schema.org/InStock' : 'http://schema.org/OutOfStock', + sku: variant.product.sku, + }))); + } else { + ldJson.offers.push({ + '@type': 'Offer', + price: amount?.value, + priceCurrency: amount?.currency, + availability: inStock ? 'http://schema.org/InStock' : 'http://schema.org/OutOfStock', + }); + } + + setJsonLd(ldJson, 'product'); } class ProductDetailPage extends Component { diff --git a/blocks/product-details/product-details.js b/blocks/product-details/product-details.js index 7ea4a88a49..e5b6765b58 100644 --- a/blocks/product-details/product-details.js +++ b/blocks/product-details/product-details.js @@ -17,7 +17,7 @@ import { getProduct, getSkuFromUrl, setJsonLd, - loadErrorPage, + loadErrorPage, performCatalogServiceQuery, variantsQuery, } from '../../scripts/commerce.js'; import { getConfigValue } from '../../scripts/configs.js'; import { fetchPlaceholders } from '../../scripts/aem.js'; @@ -37,34 +37,47 @@ async function setJsonLdProduct(product) { const amount = priceRange?.minimum?.final?.amount || price?.final?.amount; const brand = attributes.find((attr) => attr.name === 'brand'); - setJsonLd( - { - '@context': 'http://schema.org', - '@type': 'Product', - name, - description, - image: images[0]?.url, - offers: [ - { - '@type': 'http://schema.org/Offer', - price: amount?.value, - priceCurrency: amount?.currency, - availability: inStock - ? 'http://schema.org/InStock' - : 'http://schema.org/OutOfStock', - }, - ], - productID: sku, - brand: { - '@type': 'Brand', - name: brand?.value, - }, - url: new URL(`/products/${urlKey}/${sku}`, window.location), - sku, - '@id': new URL(`/products/${urlKey}/${sku}`, window.location), + // get variants + const { variants } = (await performCatalogServiceQuery(variantsQuery, { sku }))?.variants + || { variants: [] }; + + const ldJson = { + '@context': 'http://schema.org', + '@type': 'Product', + name, + description, + image: images[0]?.url, + offers: [], + productID: sku, + brand: { + '@type': 'Brand', + name: brand?.value, }, - 'product', - ); + url: new URL(`/products/${urlKey}/${sku}`, window.location), + sku, + '@id': new URL(`/products/${urlKey}/${sku}`, window.location), + }; + + if (variants.length > 1) { + ldJson.offers.push(...variants.map((variant) => ({ + '@type': 'Offer', + name: variant.product.name, + image: variant.product.images[0]?.url, + price: variant.product.price.final.amount.value, + priceCurrency: variant.product.price.final.amount.currency, + availability: variant.product.inStock ? 'http://schema.org/InStock' : 'http://schema.org/OutOfStock', + sku: variant.product.sku, + }))); + } else { + ldJson.offers.push({ + '@type': 'Offer', + price: amount?.value, + priceCurrency: amount?.currency, + availability: inStock ? 'http://schema.org/InStock' : 'http://schema.org/OutOfStock', + }); + } + + setJsonLd(ldJson, 'product'); } function createMetaTag(property, content, type) { @@ -99,13 +112,13 @@ function setMetaTags(product) { ? product.priceRange.minimum.final.amount : product.price.final.amount; - createMetaTag('title', product.metaTitle, 'name'); + createMetaTag('title', product.metaTitle || product.name, 'name'); createMetaTag('description', product.metaDescription, 'name'); createMetaTag('keywords', product.metaKeyword, 'name'); createMetaTag('og:type', 'og:product', 'property'); createMetaTag('og:description', product.shortDescription, 'property'); - createMetaTag('og:title', product.metaTitle, 'property'); + createMetaTag('og:title', product.metaTitle || product.name, 'property'); createMetaTag('og:url', window.location.href, 'property'); const mainImage = product?.images?.filter((image) => image.roles.includes('thumbnail'))[0]; const metaImage = mainImage?.url || product?.images[0]?.url; diff --git a/scripts/commerce.js b/scripts/commerce.js index 4381d516b4..0763210ea7 100644 --- a/scripts/commerce.js +++ b/scripts/commerce.js @@ -100,6 +100,28 @@ export const productDetailQuery = `query ProductQuery($sku: String!) { } ${priceFieldsFragment}`; +export const variantsQuery = ` +query($sku: String!) { + variants(sku: $sku) { + variants { + product { + sku + name + inStock + images(roles: ["image"]) { + url + } + ...on SimpleProductView { + price { + final { amount { currency value } } + } + } + } + } + } +} +`; + /* Common functionality */ export async function performCatalogServiceQuery(query, variables) { diff --git a/tools/pdp-metadata/README.md b/tools/pdp-metadata/README.md index 8c4faf6b46..28dd0421e3 100644 --- a/tools/pdp-metadata/README.md +++ b/tools/pdp-metadata/README.md @@ -2,9 +2,10 @@ ## Overview It is recommended to import product metadata into Edge Delivery so that it can be rendered server-side on product detail pages. -This is important so social media sites which do not parse JavaScript can pick it up. +This is important so Google Merchant Center can reliably verify entries from your product sheet. +Also social media sites which usually do not parse JavaScript can leverage this metadata to display rich previews of your product page links. -This project is designed to fetch product data from a catalog service, process it, and generate a metadata spreadsheet in XLSX format which can be used for the https://www.aem.live/docs/bulk-metadata feature in Edge Delivery. +This project is designed to fetch product data from catalog service, process it, and generate a metadata spreadsheet in XLSX format which can be used for the https://www.aem.live/docs/bulk-metadata feature in Edge Delivery. ## Prerequisites - Node.js installed on your machine. diff --git a/tools/pdp-metadata/pdp-metadata.js b/tools/pdp-metadata/pdp-metadata.js index cf20e23b40..5a27e65f78 100644 --- a/tools/pdp-metadata/pdp-metadata.js +++ b/tools/pdp-metadata/pdp-metadata.js @@ -2,6 +2,7 @@ import XLSX from 'xlsx'; import fs from 'fs'; import he from 'he'; import productSearchQuery from './queries/products.graphql.js'; +import { variantsFragment } from './queries/variants.graphql.js'; const basePath = 'https://main--aem-boilerplate-commerce--hlxsites.hlx.live'; const configFile = `${basePath}/configs.json?sheet=prod`; @@ -19,13 +20,14 @@ async function performCatalogServiceQuery(config, query, variables) { }; const apiCall = new URL(config['commerce-endpoint']); - apiCall.searchParams.append('query', query.replace(/(?:\r\n|\r|\n|\t|[\s]{4})/g, ' ') - .replace(/\s\s+/g, ' ')); - apiCall.searchParams.append('variables', variables ? JSON.stringify(variables) : null); const response = await fetch(apiCall, { - method: 'GET', + method: 'POST', headers, + body: JSON.stringify({ + query: query.replace(/(?:\r\n|\r|\n|\t|[\s]{4})/g, ' ').replace(/\s\s+/g, ' '), + variables, + }), }); if (!response.ok) { @@ -37,6 +39,58 @@ async function performCatalogServiceQuery(config, query, variables) { return queryResponse.data; } +function getJsonLd(product, { variants }) { + const amount = product.priceRange?.minimum?.final?.amount || product.price?.final?.amount; + const brand = product.attributes.find((attr) => attr.name === 'brand'); + + const schema = { + '@context': 'http://schema.org', + '@type': 'Product', + name: product.name, + description: product.meta_description, + image: product['og:image'], + offers: [], + productID: product.sku, + sku: product.sku, + url: product.path, + '@id': product.path, + }; + + if (brand?.value) { + product.brand = { + '@type': 'Brand', + name: brand?.value, + }; + } + + if (variants.length <= 1) { + // simple products + if (amount?.value && amount?.currency) { + schema.offers.push({ + '@type': 'Offer', + price: amount?.value, + priceCurrency: amount?.currency, + availability: product.inStock ? 'http://schema.org/InStock' : 'http://schema.org/OutOfStock', + }); + } + } else { + // complex products + variants.forEach((variant) => { + schema.offers.push({ + '@type': 'Offer', + name: variant.product.name, + image: variant.product.images[0]?.url, + price: variant.product.price.final.amount.value, + priceCurrency: variant.product.price.final.amount.currency, + availability: variant.product.inStock ? 'http://schema.org/InStock' : 'http://schema.org/OutOfStock', + sku: variant.product.sku + }); + }) + } + + return JSON.stringify(schema); +} + /** * Get products by page number * @param {INT} pageNumber - pass the pagenumber to retrieved paginated results @@ -60,7 +114,7 @@ const getProducts = async (config, pageNumber) => { description, shortDescription, } = item.productView; - const { url: imageUrl } = item.product.image ?? {}; + const { url: imageUrl } = item.productView.images?.[0] ?? { url: '' }; let baseImageUrl = imageUrl; if (baseImageUrl.startsWith('//')) { @@ -96,6 +150,9 @@ const getProducts = async (config, pageNumber) => { const totalPages = response.productSearch.page_info.total_pages; const currentPage = response.productSearch.page_info.current_page; console.log(`Retrieved page ${currentPage} of ${totalPages} pages`); + + await addVariantsToProducts(products, config); + if (currentPage !== totalPages) { return [...products, ...(await getProducts(config, currentPage + 1))]; } @@ -104,6 +161,29 @@ const getProducts = async (config, pageNumber) => { return []; }; +async function addVariantsToProducts(products, config) { + const query = ` + query Q { + ${products.map((product, i) => { + return ` + item_${i}: variants(sku: "${product.productView.sku}") { + ...ProductVariant + } + ` + }).join('\n')} + }${variantsFragment}`; + + const response = await performCatalogServiceQuery(config, query, null); + + if (!response) { + throw new Error('Could not fetch variants'); + } + + products.forEach((product, i) => { + product.variants = response[`item_${i}`]; + }); +} + (async () => { const config = {}; try { @@ -130,9 +210,10 @@ const getProducts = async (config, pageNumber) => { 'og:url', 'og:image', 'og:image:secure_url', + 'json-ld', ], ]; - products.forEach(({ productView: metaData }) => { + products.forEach(({ productView: metaData, variants }) => { data.push( [ metaData.path, // URL @@ -145,6 +226,7 @@ const getProducts = async (config, pageNumber) => { `${basePath}${metaData.path}`, // og:url metaData['og:image'], // og:image metaData['og:image:secure_url'], // og:image:secure_url + getJsonLd(metaData, variants), // json-ld ], ); }); diff --git a/tools/pdp-metadata/queries/products.graphql.js b/tools/pdp-metadata/queries/products.graphql.js index d631215250..b5e3f6d3da 100644 --- a/tools/pdp-metadata/queries/products.graphql.js +++ b/tools/pdp-metadata/queries/products.graphql.js @@ -1,21 +1,40 @@ export default `query productSearch($currentPage: Int = 1) { - productSearch(current_page: $currentPage, page_size: 20, phrase: "") { + productSearch(current_page: $currentPage, page_size: 50, phrase: "") { items { productView { __typename sku name urlKey + url shortDescription description metaDescription metaKeyword metaTitle - } - product { - image { + inStock + images(roles: ["image"]) { url } + attributes(roles: []) { + name + value + } + ... on SimpleProductView { + price { + ...priceFields + } + } + ... on ComplexProductView { + priceRange { + maximum { + ...priceFields + } + minimum { + ...priceFields + } + } + } } } page_info { @@ -25,4 +44,19 @@ export default `query productSearch($currentPage: Int = 1) { } total_count } -}`; +} +fragment priceFields on ProductViewPrice { + regular { + amount { + currency + value + } + } + final { + amount { + currency + value + } + } +} +`; diff --git a/tools/pdp-metadata/queries/variants.graphql.js b/tools/pdp-metadata/queries/variants.graphql.js new file mode 100644 index 0000000000..2e2274c470 --- /dev/null +++ b/tools/pdp-metadata/queries/variants.graphql.js @@ -0,0 +1,26 @@ +export default ` +query($sku: String!) { + variants(sku: $sku) { + ...ProductVariant + } +}`; + +export const variantsFragment = ` +fragment ProductVariant on ProductViewVariantResults { + variants { + product { + sku + name + inStock + images(roles: ["image"]) { + url + } + ...on SimpleProductView { + price { + final { amount { currency value } } + } + } + } + } +} +`;