diff --git a/CHANGELOG.md b/CHANGELOG.md index cc5af8e..1f33bf1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,31 @@ +## [1.6.3](https://github.com/adobe-rnd/helix-commerce-api/compare/v1.6.2...v1.6.3) (2024-11-14) + + +### Bug Fixes + +* shortdescription instead of description ([#47](https://github.com/adobe-rnd/helix-commerce-api/issues/47)) ([c325894](https://github.com/adobe-rnd/helix-commerce-api/commit/c325894ff56f6942faf44993d485399535ae1b5e)) + +## [1.6.2](https://github.com/adobe-rnd/helix-commerce-api/compare/v1.6.1...v1.6.2) (2024-11-13) + + +### Bug Fixes + +* brand, itemCondition and hasMerchantReturnPolicy ([#46](https://github.com/adobe-rnd/helix-commerce-api/issues/46)) ([fe6970e](https://github.com/adobe-rnd/helix-commerce-api/commit/fe6970e5d8de48303fff33573bd24fc099b404c6)) + +## [1.6.1](https://github.com/adobe-rnd/helix-commerce-api/compare/v1.6.0...v1.6.1) (2024-11-12) + + +### Bug Fixes + +* various stuff ([#45](https://github.com/adobe-rnd/helix-commerce-api/issues/45)) ([a77f3d5](https://github.com/adobe-rnd/helix-commerce-api/commit/a77f3d577ff30c10a18721def3630576b8424611)) + +# [1.6.0](https://github.com/adobe-rnd/helix-commerce-api/compare/v1.5.5...v1.6.0) (2024-11-11) + + +### Features + +* add ratings to json-ld ([#44](https://github.com/adobe-rnd/helix-commerce-api/issues/44)) ([76858f4](https://github.com/adobe-rnd/helix-commerce-api/commit/76858f480bd826cdfebcbab3dff348e03787e8ae)) + ## [1.5.5](https://github.com/adobe-rnd/helix-commerce-api/compare/v1.5.4...v1.5.5) (2024-11-07) diff --git a/package-lock.json b/package-lock.json index 68d44a5..f0c9108 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "helix-commerce-api", - "version": "1.5.5", + "version": "1.6.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "helix-commerce-api", - "version": "1.5.5", + "version": "1.6.3", "license": "Apache-2.0", "devDependencies": { "@adobe/eslint-config-helix": "2.0.6", diff --git a/package.json b/package.json index 55e2d61..362f9c2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "helix-commerce-api", - "version": "1.5.5", + "version": "1.6.3", "private": true, "description": "API for markup content and a commerce graphql commerce proxy", "main": "src/index.js", diff --git a/src/content/adobe-commerce.js b/src/content/adobe-commerce.js index 8b88319..439f119 100644 --- a/src/content/adobe-commerce.js +++ b/src/content/adobe-commerce.js @@ -54,7 +54,7 @@ async function fetchProduct(sku, config) { if (!productData) { throw errorWithResponse(404, 'could not find product', json.errors); } - const product = productAdapter(productData); + const product = productAdapter(config, productData); return product; } catch (e) { console.error('failed to parse product: ', e); diff --git a/src/content/handler.js b/src/content/handler.js index 9dacd9d..c9bc60e 100644 --- a/src/content/handler.js +++ b/src/content/handler.js @@ -29,7 +29,7 @@ export default async function contentHandler(ctx) { if (!config.pageType) { return errorResponse(400, 'invalid config for tenant site (missing pageType)'); } - console.log('config: ', JSON.stringify(config, null, 2)); + ctx.log.debug('config: ', JSON.stringify(config, null, 2)); if (config.catalogSource === 'helix') { return handleHelixCommerce(ctx); diff --git a/src/content/queries/cs-product.js b/src/content/queries/cs-product.js index 8468b01..335880a 100644 --- a/src/content/queries/cs-product.js +++ b/src/content/queries/cs-product.js @@ -11,13 +11,14 @@ */ import { forceImagesHTTPS } from '../../utils/http.js'; -import { gql, parseSpecialToDate } from '../../utils/product.js'; +import { gql, parseRating, parseSpecialToDate } from '../../utils/product.js'; /** + * @param {Config} config * @param {any} productData * @returns {Product} */ -export const adapter = (productData) => { +export const adapter = (config, productData) => { let minPrice = productData.priceRange?.minimum ?? productData.price; let maxPrice = productData.priceRange?.maximum ?? productData.price; @@ -42,6 +43,8 @@ export const adapter = (productData) => { externalId: productData.externalId, images: forceImagesHTTPS(productData.images) ?? [], attributes: productData.attributes ?? [], + attributeMap: Object.fromEntries((productData.attributes ?? []) + .map(({ name, value }) => [name, value])), options: (productData.options ?? []).map((option) => ({ id: option.id, label: option.title, @@ -76,7 +79,6 @@ export const adapter = (productData) => { currency: minPrice.regular.amount.currency, maximumAmount: maxPrice.regular.amount.value, minimumAmount: minPrice.regular.amount.value, - // TODO: add variant? }, final: { // TODO: determine whether to use min or max @@ -84,17 +86,27 @@ export const adapter = (productData) => { currency: minPrice.final.amount.currency, maximumAmount: maxPrice.final.amount.value, minimumAmount: minPrice.final.amount.value, - // TODO: add variant? }, visible: minPrice.roles?.includes('visible'), } : null, }; + if (config.attributeOverrides?.product) { + Object.entries(config.attributeOverrides.product).forEach(([key, value]) => { + product.attributeMap[key] = product.attributeMap[value] ?? product[key]; + }); + } + const specialToDate = parseSpecialToDate(product); if (specialToDate) { product.specialToDate = specialToDate; } + const rating = parseRating(product); + if (rating) { + product.rating = rating; + } + return product; }; diff --git a/src/content/queries/cs-variants.js b/src/content/queries/cs-variants.js index 4314043..743af4b 100644 --- a/src/content/queries/cs-variants.js +++ b/src/content/queries/cs-variants.js @@ -11,9 +11,10 @@ */ import { forceImagesHTTPS } from '../../utils/http.js'; -import { gql, parseSpecialToDate } from '../../utils/product.js'; +import { gql, parseRating, parseSpecialToDate } from '../../utils/product.js'; /** + * @param {Config} config * @param {any} variants * @returns {Variant[]} */ @@ -30,6 +31,8 @@ export const adapter = (config, variants) => variants.map(({ selections, product inStock: product.inStock, images: forceImagesHTTPS(product.images) ?? [], attributes: product.attributes ?? [], + attributeMap: Object.fromEntries((product.attributes ?? []) + .map(({ name, value }) => [name, value])), externalId: product.externalId, prices: { regular: { @@ -50,15 +53,20 @@ export const adapter = (config, variants) => variants.map(({ selections, product selections: (selections ?? []).sort(), }; - const specialToDate = parseSpecialToDate(product); + if (config.attributeOverrides?.variant) { + Object.entries(config.attributeOverrides.variant).forEach(([key, value]) => { + variant.attributeMap[key] = variant.attributeMap[value] ?? variant[key]; + }); + } + + const specialToDate = parseSpecialToDate(variant); if (specialToDate) { variant.specialToDate = specialToDate; } - if (config.attributeOverrides?.variant) { - Object.entries(config.attributeOverrides.variant).forEach(([key, value]) => { - variant[key] = product.attributes?.find((attr) => attr.name === value)?.value; - }); + const rating = parseRating(variant); + if (rating) { + variant.rating = rating; } return variant; diff --git a/src/templates/html/overrides/wilson-ecommerce--wilson.js b/src/templates/html/overrides/wilson-ecommerce--wilson.js index 3f54598..36a2e9a 100644 --- a/src/templates/html/overrides/wilson-ecommerce--wilson.js +++ b/src/templates/html/overrides/wilson-ecommerce--wilson.js @@ -20,8 +20,8 @@ export default class extends HTMLTemplate { */ constructor(ctx, product, variants) { super(ctx, product, variants); - // use description field for meta description, if not explicitly set - this.product.metaDescription = this.product.metaDescription || this.product.description; + // use shortDescription field for meta description, if not explicitly set + this.product.metaDescription = this.product.metaDescription || this.product.shortDescription; } /** diff --git a/src/templates/json/JSONTemplate.js b/src/templates/json/JSONTemplate.js index 18cd3fe..139bbba 100644 --- a/src/templates/json/JSONTemplate.js +++ b/src/templates/json/JSONTemplate.js @@ -72,14 +72,17 @@ export class JSONTemplate { * @param {Variant} [variant] */ constructMPN(variant) { + const { attributeMap: productAttrs } = this.product; + const { attributeMap: variantAttrs } = variant || {}; + return variant - ? variant.attributes.find((attr) => attr.name.toLowerCase() === 'mpn')?.value ?? this.constructMPN() - : this.product.attributes.find((attr) => attr.name.toLowerCase() === 'mpn')?.value ?? undefined; + ? variantAttrs.mpn ?? this.constructMPN() + : productAttrs.mpn ?? undefined; } renderBrand() { - const { attributes } = this.product; - const brandName = attributes?.find((attr) => attr.name === 'brand')?.value; + const { attributeMap: attrs } = this.product; + const brandName = attrs.brand; if (!brandName) { return undefined; } @@ -91,53 +94,71 @@ export class JSONTemplate { }; } + /** + * @param {Variant} [variant] + */ + renderRating(variant) { + const { rating } = variant || this.product; + if (!rating) { + return undefined; + } + + const { + count, + reviews, + value, + best, + worst, + } = rating; + return pruneUndefined({ + '@type': 'AggregateRating', + ratingValue: value, + ratingCount: count, + reviewCount: reviews, + bestRating: best, + worstRating: worst, + }); + } + renderOffers() { const image = this.product.images?.[0]?.url ?? findProductImage(this.product, this.variants)?.url; const configurableProduct = this.variants?.length > 0; const offers = configurableProduct ? this.variants : [this.product]; - return { - offers: [ - ...offers.map((v) => { - const { prices: variantPrices } = v; - const offerUrl = this.constructProductURL(configurableProduct ? v : undefined); - const mpn = this.constructMPN(configurableProduct ? v : undefined); - const finalPrice = variantPrices?.final?.amount; - const regularPrice = variantPrices?.regular?.amount; - - const offer = { - '@type': 'Offer', - sku: v.sku, - mpn, - url: offerUrl, - image: v.images?.[0]?.url ?? image, - availability: v.inStock ? 'InStock' : 'OutOfStock', - price: finalPrice, - priceCurrency: variantPrices.final?.currency, - }; - - if (finalPrice < regularPrice) { - offer.priceSpecification = this.renderOffersPriceSpecification(v); - } - - if (v.gtin) { - offer.gtin = v.gtin; - } - - if (v.specialToDate) { - offer.priceValidUntil = v.specialToDate; - } - - return pruneUndefined(offer); - }).filter(Boolean), - ], - }; + return offers.map((v) => { + const { prices: variantPrices } = v; + const offerUrl = this.constructProductURL(configurableProduct ? v : undefined); + const mpn = this.constructMPN(configurableProduct ? v : undefined); + const finalPrice = variantPrices?.final?.amount; + const regularPrice = variantPrices?.regular?.amount; + + const offer = { + '@type': 'Offer', + sku: v.sku, + mpn, + url: offerUrl, + image: v.images?.[0]?.url ?? image, + availability: v.inStock ? 'InStock' : 'OutOfStock', + price: finalPrice, + priceCurrency: variantPrices.final?.currency, + gtin: v.attributeMap.gtin, + priceValidUntil: v.specialToDate, + aggregateRating: this.renderRating(v), + }; + + if (finalPrice < regularPrice) { + offer.priceSpecification = this.renderOffersPriceSpecification(v); + } + + return pruneUndefined(offer); + }); } + /** + * @param {Variant} variant + */ renderOffersPriceSpecification(variant) { - const { prices } = variant; - const { regular } = prices; - const { amount, currency } = regular; + const { prices: { regular: { amount, currency } } } = variant; return { '@type': 'UnitPriceSpecification', priceType: 'https://schema.org/ListPrice', @@ -152,13 +173,21 @@ export class JSONTemplate { name, metaDescription, images, - reviewCount, - ratingValue, } = this.product; const productUrl = this.constructProductURL(); const mpn = this.constructMPN(); const image = images?.[0]?.url ?? findProductImage(this.product, this.variants)?.url; + const offers = this.renderOffers(); + + // if offers don't have an aggregate rating + // the top-level product may have one that applies to all variants + const offersHaveRating = offers.some((o) => o.aggregateRating); + let aggregateRating; + if (!offersHaveRating) { + aggregateRating = this.renderRating(); + } + return JSON.stringify(pruneUndefined({ '@context': 'http://schema.org', '@type': 'Product', @@ -170,19 +199,9 @@ export class JSONTemplate { description: metaDescription, image, productID: sku, - ...this.renderOffers(), + offers, + aggregateRating, ...(this.renderBrand() ?? {}), - ...(typeof reviewCount === 'number' - && typeof ratingValue === 'number' - && reviewCount > 0 - ? { - aggregateRating: { - '@type': 'AggregateRating', - ratingValue, - reviewCount, - }, - } - : {}), }), undefined, 2); } } diff --git a/src/templates/json/overrides/thepixel--bul-eds.js b/src/templates/json/overrides/thepixel--bul-eds.js index 96202f3..0d90fee 100644 --- a/src/templates/json/overrides/thepixel--bul-eds.js +++ b/src/templates/json/overrides/thepixel--bul-eds.js @@ -37,4 +37,30 @@ export default class extends JSONTemplate { return productUrl; } + + // eslint-disable-next-line class-methods-use-this + renderBrand() { + return { + brand: { + '@type': 'Brand', + name: 'Bulk', + }, + }; + } + + renderOffers() { + const baseOffers = super.renderOffers(); + return baseOffers.map((o) => ({ + ...o, + itemCondition: 'http://schema.org/NewCondition', + hasMerchantReturnPolicy: { + '@type': 'MerchantReturnPolicy', + applicableCountry: 'UK', + returnPolicyCategory: 'https://schema.org/MerchantReturnFiniteReturnWindow', + merchantReturnDays: 30, + returnMethod: 'https://schema.org/ReturnByMail', + returnFees: 'https://schema.org/ReturnFeesCustomerResponsibility', + }, + })); + } } diff --git a/src/templates/json/overrides/wilson-ecommerce--wilson.js b/src/templates/json/overrides/wilson-ecommerce--wilson.js index 5f1bb5f..7ee397f 100644 --- a/src/templates/json/overrides/wilson-ecommerce--wilson.js +++ b/src/templates/json/overrides/wilson-ecommerce--wilson.js @@ -33,12 +33,10 @@ export default class extends JSONTemplate { } renderOffers() { - const { offers: baseOffers } = super.renderOffers(); - return { - offers: baseOffers.map((o) => ({ - ...o, - itemCondition: 'http://schema.org/NewCondition', - })), - }; + const baseOffers = super.renderOffers(); + return baseOffers.map((o) => ({ + ...o, + itemCondition: 'http://schema.org/NewCondition', + })); } } diff --git a/src/types.d.ts b/src/types.d.ts index 796f3a4..615d6af 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -10,8 +10,13 @@ declare global { export interface AttributeOverrides { variant: { + // expected attribute name => actual attribute name [key: string]: string; }; + product: { + // expected attribute name => actual attribute name + [key: string]: string; + } } /** @@ -36,14 +41,14 @@ declare global { sku?: string; matchedPatterns: string[]; imageRoles?: string[]; - - confMap: ConfigMap; + host: string; params: Record; headers: Record; - host: string; - offerVariantURLTemplate: string; - attributeOverrides: AttributeOverrides; - siteOverrides: Record>; + offerVariantURLTemplate?: string; + attributeOverrides?: AttributeOverrides; + siteOverrides?: Record>; + + confMap: ConfigMap; } export interface Env { @@ -91,13 +96,15 @@ declare global { externalId?: string; variants?: Variant[]; // variants exist on products in helix commerce but not on magento specialToDate?: string; + rating?: Rating // not handled currently: externalParentId?: string; variantSku?: string; - reviewCount?: number; - ratingValue?: number; optionUIDs?: string[]; + + // internal use: + attributeMap: Record; } export interface Variant { @@ -113,6 +120,23 @@ declare global { externalId: string; specialToDate?: string; gtin?: string; + rating?: Rating; + + // internal use: + attributeMap: Record; + } + + interface Rating { + // number of ratings + count?: number; + // number of reviews + reviews?: number; + // rating value + value: number | string; + // range of ratings, highest + best?: number | string; + // range of ratings, lowest + worst?: number | string; } interface Image { diff --git a/src/utils/product.js b/src/utils/product.js index b26da57..0d6017e 100644 --- a/src/utils/product.js +++ b/src/utils/product.js @@ -12,6 +12,10 @@ import { errorWithResponse } from './http.js'; +/** + * @param {string} str + * @returns {boolean} + */ export const hasUppercase = (str) => /[A-Z]/.test(str); /** @@ -34,11 +38,20 @@ export function gql(strs, ...params) { /** * This function removes all undefined values from an object. - * @param {Record} obj - The object to prune. - * @returns {Record} - The pruned object. + * @template {Record} T + * @param {T} obj - The object to prune. + * @param {boolean} [pruneNullish=false] - Whether to remove nullish values. + * @returns {Partial} - The pruned object. */ -export function pruneUndefined(obj) { - return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined)); +export function pruneUndefined(obj, pruneNullish = false) { + // @ts-ignore + return Object.fromEntries( + Object.entries(obj) + .filter(([, v]) => (pruneNullish + ? v != null + : v !== undefined + )), + ); } /** @@ -47,7 +60,7 @@ export function pruneUndefined(obj) { * If no in-stock variant, returns first variant image * * @param {Product} product - The product object. - * @param {Variant[]} [variants] - The variants array. + * @param {Variant[]} [variants=[]] - The variants array. * @returns {Product['images'][number]} - The product image. */ export function findProductImage(product, variants = []) { @@ -72,34 +85,10 @@ export function assertValidProduct(product) { } /** - * @param {Config} config - * @param {string} path - * @returns {string} matched path key + * @param {Product|Variant} product */ -export function matchConfigPath(config, path) { - // Filter out any keys that are not paths - const pathEntries = Object.entries(config.confMap).filter(([key]) => key !== 'base'); - - for (const [key] of pathEntries) { - // Replace `{{urlkey}}` and `{{sku}}` with regex patterns - const pattern = key - .replace('{{urlkey}}', '([^]+)') - .replace('{{sku}}', '([^]+)'); - - // Convert to regex and test against the path - const regex = new RegExp(`^${pattern}$`); - const match = path.match(regex); - - if (match) { - return key; - } - } - console.warn('No match found for path:', path); - return null; -} - export function parseSpecialToDate(product) { - const specialToDate = product.attributes?.find((attr) => attr.name === 'special_to_date')?.value; + const specialToDate = product.attributeMap.special_to_date; if (specialToDate) { const today = new Date(); const specialPriceToDate = new Date(specialToDate); @@ -139,3 +128,29 @@ export function getPreviewPublishPaths(config, sku, urlKey) { return previewPublishPaths; } + +/** + * @param {Product|Variant} product + * @returns {Rating | undefined} + */ +export function parseRating(product) { + const { attributeMap: attrs } = product; + /** @type {Rating} */ + // @ts-ignore + const rating = pruneUndefined({ + count: attrs['rating-count'] ? Number.parseInt(attrs['rating-count'], 10) : undefined, + reviews: attrs['review-count'] ? Number.parseInt(attrs['review-count'], 10) : undefined, + value: attrs['rating-value'], + best: attrs['best-rating'], + worst: attrs['worst-rating'], + }, true); + + // at least one of count, reviews, or value must exist + if (rating.value != null + || ['count', 'reviews'].some( + (key) => rating[key] != null && !Number.isNaN(rating[key]), + )) { + return rating; + } + return undefined; +} diff --git a/test/content/handler.test.js b/test/content/handler.test.js index d5d5c96..71ddb04 100644 --- a/test/content/handler.test.js +++ b/test/content/handler.test.js @@ -58,6 +58,7 @@ describe('contentHandler', () => { it('calls handleHelixCommerce if catalogSource is helix', async () => { const ctx = { + log: { debug: () => {} }, info: { method: 'GET' }, url: { pathname: '/content/product/us/p/product-urlkey' }, config: { @@ -76,6 +77,7 @@ describe('contentHandler', () => { it('calls handleAdobeCommerce', async () => { const ctx = { + log: { debug: () => {} }, info: { method: 'GET' }, url: { pathname: '/content/product/us/p/product-urlkey' }, config: { diff --git a/test/fixtures/product.js b/test/fixtures/product.js index c494ab2..55ffa49 100644 --- a/test/fixtures/product.js +++ b/test/fixtures/product.js @@ -98,6 +98,10 @@ export function createProductFixture(overrides = {}) { ...overrides, }; + product.attributeMap = Object.fromEntries( + product.attributes.map(({ name, value }) => [name, value]), + ); + // Deep merge defaults with overrides return product; } diff --git a/test/fixtures/variant.js b/test/fixtures/variant.js index cf6df00..d2ee4a7 100644 --- a/test/fixtures/variant.js +++ b/test/fixtures/variant.js @@ -37,6 +37,7 @@ export function createProductVariationFixture(overrides = {}) { { name: 'criteria_7', label: 'Criteria 7', value: 'Wattage: 40 T10' }, { name: 'barcode', label: 'Barcode', value: '123456789012' }, { name: 'weight', label: 'Weight', value: 219 }, + ...(overrides.attributes || []), ], prices: { regular: { @@ -59,6 +60,10 @@ export function createProductVariationFixture(overrides = {}) { ...overrides, }; + variation.attributeMap = Object.fromEntries( + variation.attributes.map(({ name, value }) => [name, value]), + ); + return variation; } diff --git a/test/templates/html/index.test.js b/test/templates/html/index.test.js index 9d9c04a..10e96a5 100644 --- a/test/templates/html/index.test.js +++ b/test/templates/html/index.test.js @@ -145,8 +145,8 @@ describe('Render Product HTML', () => { it('should have the correct JSON-LD schema with attribute overrides', () => { variations = [ - createProductVariationFixture({ gtin: '123' }), - createProductVariationFixture({ gtin: '456' }), + createProductVariationFixture({ attributes: [{ name: 'gtin', value: '123' }] }), + createProductVariationFixture({ attributes: [{ name: 'gtin', value: '456' }] }), ]; const html = htmlTemplateFromContext(DEFAULT_CONTEXT({ config }), product, variations).render(); dom = new JSDOM(html); @@ -157,7 +157,7 @@ describe('Render Product HTML', () => { jsonLd.offers.forEach((offer, index) => { const variant = variations[index]; - assert.strictEqual(offer.gtin, variant.gtin, `Offer gtin for variant ${variant.sku} does not match`); + assert.strictEqual(offer.gtin, variant.attributeMap.gtin, `Offer gtin for variant ${variant.sku} does not match`); }); });