Skip to content

Commit

Permalink
Merge pull request #39 from adobe-rnd/listprice
Browse files Browse the repository at this point in the history
feat: add support for ListPrice and priceValidUntil in JSON-LD
  • Loading branch information
dylandepass authored Nov 5, 2024
2 parents e4b2dfb + f484693 commit e84a1e0
Show file tree
Hide file tree
Showing 7 changed files with 194 additions and 44 deletions.
7 changes: 6 additions & 1 deletion src/content/queries/cs-product.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
*/

import { forceImagesHTTPS } from '../../utils/http.js';
import { gql } from '../../utils/product.js';
import { gql, parseSpecialToDate } from '../../utils/product.js';

/**
* @param {any} productData
Expand Down Expand Up @@ -90,6 +90,11 @@ export const adapter = (productData) => {
} : null,
};

const specialToDate = parseSpecialToDate(product);
if (specialToDate) {
product.specialToDate = specialToDate;
}

return product;
};

Expand Down
8 changes: 7 additions & 1 deletion src/content/queries/cs-variants.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
*/

import { forceImagesHTTPS } from '../../utils/http.js';
import { gql } from '../../utils/product.js';
import { gql, parseSpecialToDate } from '../../utils/product.js';

/**
* @param {any} variants
Expand Down Expand Up @@ -49,6 +49,12 @@ export const adapter = (config, variants) => variants.map(({ selections, product
},
selections: selections ?? [],
};

const specialToDate = parseSpecialToDate(product);
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;
Expand Down
8 changes: 8 additions & 0 deletions src/templates/html/HTMLTemplate.js
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,10 @@ ${HTMLTemplate.metaProperty('product:price.currency', product.prices.final.curre
* @returns {string}
*/
renderProductVariants() {
if (!this.variants) {
return '';
}

return /* html */ `\
<div class="product-variants">
${this.variants.map((v) => /* html */`\
Expand All @@ -292,6 +296,10 @@ ${HTMLTemplate.metaProperty('product:price.currency', product.prices.final.curre
* @returns {string}
*/
renderProductVariantsAttributes() {
if (!this.variants) {
return '';
}

return /* html */ `\
<div class="variant-attributes">
${this.variants?.map((v) => /* html */`\
Expand Down
86 changes: 55 additions & 31 deletions src/templates/json/JSONTemplate.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
* governing permissions and limitations under the License.
*/

/* eslint-disable class-methods-use-this */

import { findProductImage, pruneUndefined } from '../../utils/product.js';

export class JSONTemplate {
Expand Down Expand Up @@ -80,6 +82,58 @@ export class JSONTemplate {
};
}

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 offerUrl = this.constructProductURL(configurableProduct ? v : undefined);
const { prices: variantPrices } = v;
const finalPrice = variantPrices?.final?.amount;
const regularPrice = variantPrices?.regular?.amount;
const offer = {
'@type': 'Offer',
sku: v.sku,
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 offer;
}).filter(Boolean),
],
};
}

renderOffersPriceSpecification(variant) {
const { prices } = variant;
const { regular } = prices;
const { amount, currency } = regular;
return {
'@type': 'UnitPriceSpecification',
priceType: 'https://schema.org/ListPrice',
price: amount,
priceCurrency: currency,
};
}

render() {
const {
sku,
Expand All @@ -88,8 +142,6 @@ export class JSONTemplate {
images,
reviewCount,
ratingValue,
inStock,
prices,
} = this.product;

const productUrl = this.constructProductURL();
Expand All @@ -103,35 +155,7 @@ export class JSONTemplate {
description: metaDescription,
image,
productID: sku,
offers: [
prices ? ({
'@type': 'Offer',
sku,
url: productUrl,
image,
availability: inStock ? 'InStock' : 'OutOfStock',
price: prices?.final?.amount,
priceCurrency: prices?.final?.currency,
}) : undefined,
...this.variants.map((v) => {
const offerUrl = this.constructProductURL(v);
const offer = {
'@type': 'Offer',
sku: v.sku,
url: offerUrl,
image: v.images?.[0]?.url ?? image,
availability: v.inStock ? 'InStock' : 'OutOfStock',
price: v.prices?.final?.amount,
priceCurrency: v.prices?.final?.currency,
};

if (v.gtin) {
offer.gtin = v.gtin;
}

return offer;
}).filter(Boolean),
],
...this.renderOffers(),
...(this.renderBrand() ?? {}),
...(typeof reviewCount === 'number'
&& typeof ratingValue === 'number'
Expand Down
2 changes: 2 additions & 0 deletions src/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ declare global {
urlKey?: string;
externalId?: string;
variants?: Variant[]; // variants exist on products in helix commerce but not on magento
specialToDate?: string;

// not handled currently:
externalParentId?: string;
Expand All @@ -110,6 +111,7 @@ declare global {
selections: string[];
attributes: Attribute[];
externalId: string;
specialToDate?: string;
gtin?: string;
}

Expand Down
13 changes: 13 additions & 0 deletions src/utils/product.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,16 @@ export function matchConfigPath(config, path) {
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;
if (specialToDate) {
const today = new Date();
const specialPriceToDate = new Date(specialToDate);
if (specialPriceToDate.getTime() >= today.getTime()) {
const [date] = specialToDate.split(' ');
return date;
}
}
return undefined;
}
114 changes: 103 additions & 11 deletions test/templates/html/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

import assert from 'node:assert';
import { JSDOM } from 'jsdom';
// import { constructProductUrl } from '../../../src/utils/product.js';
import { JSONTemplate } from '../../../src/templates/json/JSONTemplate.js';
import { DEFAULT_CONTEXT } from '../../fixtures/context.js';
import { createDefaultVariations, createProductVariationFixture } from '../../fixtures/variant.js';
import { createProductFixture } from '../../fixtures/product.js';
Expand Down Expand Up @@ -77,30 +77,69 @@ describe('Render Product HTML', () => {
assert.strictEqual(twitterDescription.getAttribute('content'), expectedDescription, 'Twitter description does not match expected value');
});

it('should have the correct JSON-LD schema', () => {
it('should have the correct JSON-LD schema with variants', () => {
const jsonLdScript = document.querySelector('script[type="application/ld+json"]');
assert.ok(jsonLdScript, 'JSON-LD script tag should exist');
// @ts-ignore
const productTemplate = new JSONTemplate(DEFAULT_CONTEXT({ config }), product, variations);

const jsonLd = JSON.parse(jsonLdScript.textContent);
assert.strictEqual(jsonLd['@type'], 'Product', 'JSON-LD @type should be Product');
// assert.strictEqual(jsonLd['@id'], constructProductUrl(config, product), 'JSON-LD @id does not match product URL');
assert.strictEqual(jsonLd['@id'], productTemplate.constructProductURL(), 'JSON-LD @id does not match product URL');
assert.strictEqual(jsonLd.name, product.name, 'JSON-LD name does not match product name');
assert.strictEqual(jsonLd.sku, product.sku, 'JSON-LD SKU does not match product SKU');
assert.strictEqual(jsonLd.description, product.metaDescription, 'JSON-LD description does not match product description');
assert.strictEqual(jsonLd.image, product.images[0]?.url || '', 'JSON-LD image does not match product image');
assert.strictEqual(jsonLd.productID, product.sku, 'JSON-LD productID does not match product SKU');
assert.ok(Array.isArray(jsonLd.offers), 'JSON-LD offers should be an array');
assert.strictEqual(jsonLd.offers.length, variations.length + 1, 'JSON-LD offers length does not match number of variants');
assert.strictEqual(jsonLd.offers.length, variations.length, 'JSON-LD offers length does not match number of variants');

jsonLd.offers.forEach((offer, index) => {
const variant = index === 0 ? product : variations[index - 1];
const variant = variations[index];
assert.strictEqual(offer['@type'], 'Offer', `Offer type for variant ${variant.sku} should be Offer`);
assert.strictEqual(offer.sku, variant.sku, `Offer SKU for variant ${variant.sku} does not match`);
// assert.strictEqual(offer.url, constructProductUrl(config, product, index === 0 ? undefined : variant), 'JSON-LD offer URL does not match');
assert.strictEqual(offer.url, productTemplate.constructProductURL(variant), 'JSON-LD offer URL does not match');
assert.strictEqual(offer.price, variant.prices.final.amount, `Offer price for variant ${variant.sku} does not match`);
assert.strictEqual(offer.priceCurrency, variant.prices.final.currency, `Offer priceCurrency for variant ${variant.sku} does not match`);
assert.strictEqual(offer.availability, variant.inStock ? 'InStock' : 'OutOfStock', `Offer availability for variant ${variant.sku} does not match`);
assert.strictEqual(offer.image, variant.images[0].url || '', `Offer image for variant ${variant.sku} does not match`);
assert.strictEqual(offer.priceSpecification, undefined, 'Offer contains priceSpecification for variant when it should not');
});
});

it('should have the correct JSON-LD schema without variants (simple product)', () => {
variations = undefined;
product.specialToDate = '2024-12-31';

const html = htmlTemplateFromContext(DEFAULT_CONTEXT({ config }), product, variations).render();
dom = new JSDOM(html);
document = dom.window.document;

const jsonLdScript = document.querySelector('script[type="application/ld+json"]');

// @ts-ignore
const productTemplate = new JSONTemplate(DEFAULT_CONTEXT({ config }), product, undefined);

const jsonLd = JSON.parse(jsonLdScript.textContent);
assert.strictEqual(jsonLd['@type'], 'Product', 'JSON-LD @type should be Product');
assert.strictEqual(jsonLd['@id'], productTemplate.constructProductURL(), 'JSON-LD @id does not match product URL');
assert.strictEqual(jsonLd.name, product.name, 'JSON-LD name does not match product name');
assert.strictEqual(jsonLd.sku, product.sku, 'JSON-LD SKU does not match product SKU');
assert.strictEqual(jsonLd.description, product.metaDescription, 'JSON-LD description does not match product description');
assert.strictEqual(jsonLd.image, product.images[0]?.url || '', 'JSON-LD image does not match product image');
assert.strictEqual(jsonLd.productID, product.sku, 'JSON-LD productID does not match product SKU');
assert.ok(Array.isArray(jsonLd.offers), 'JSON-LD offers should be an array');
assert.strictEqual(jsonLd.offers.length, 1, 'JSON-LD offers length does not match number of variants');
jsonLd.offers.forEach((offer) => {
assert.strictEqual(offer['@type'], 'Offer', `Offer type for variant ${product.sku} should be Offer`);
assert.strictEqual(offer.sku, product.sku, `Offer SKU for variant ${product.sku} does not match`);
assert.strictEqual(offer.url, productTemplate.constructProductURL(), 'JSON-LD offer URL does not match');
assert.strictEqual(offer.price, product.prices.final.amount, `Offer price for variant ${product.sku} does not match`);
assert.strictEqual(offer.priceCurrency, product.prices.final.currency, `Offer priceCurrency for variant ${product.sku} does not match`);
assert.strictEqual(offer.availability, product.inStock ? 'InStock' : 'OutOfStock', `Offer availability for variant ${product.sku} does not match`);
assert.strictEqual(offer.image, product.images[0].url || '', `Offer image for variant ${product.sku} does not match`);
assert.strictEqual(offer.priceSpecification, undefined, 'Offer contains priceSpecification for variant when it should not');
assert.strictEqual(offer.priceValidUntil, '2024-12-31', 'Offer does not contain priceValidUntil for variant');
});
});

Expand All @@ -117,7 +156,7 @@ describe('Render Product HTML', () => {
const jsonLd = JSON.parse(jsonLdScript.textContent);

jsonLd.offers.forEach((offer, index) => {
const variant = index === 0 ? product : variations[index - 1];
const variant = variations[index];
assert.strictEqual(offer.gtin, variant.gtin, `Offer gtin for variant ${variant.sku} does not match`);
});
});
Expand All @@ -135,10 +174,63 @@ describe('Render Product HTML', () => {
const jsonLdScript = document.querySelector('script[type="application/ld+json"]');
const jsonLd = JSON.parse(jsonLdScript.textContent);

assert.strictEqual(jsonLd.offers[0].url, 'https://example.com/us/p/test-product-url-key/test-sku', 'JSON-LD offer URL does not match');
assert.strictEqual(jsonLd.offers[1].url, 'https://example.com/us/p/test-product-url-key?selected_product=test-sku-1', 'JSON-LD offer URL does not match');
assert.strictEqual(jsonLd.offers[2].url, 'https://example.com/us/p/test-product-url-key?selected_product=test-sku-2', 'JSON-LD offer URL does not match');
assert.strictEqual(jsonLd.offers[3].url, 'https://example.com/us/p/test-product-url-key?selected_product=test-sku-3', 'JSON-LD offer URL does not match');
assert.strictEqual(jsonLd.offers[0].url, 'https://example.com/us/p/test-product-url-key?selected_product=test-sku-1', 'JSON-LD offer URL does not match');
assert.strictEqual(jsonLd.offers[1].url, 'https://example.com/us/p/test-product-url-key?selected_product=test-sku-2', 'JSON-LD offer URL does not match');
assert.strictEqual(jsonLd.offers[2].url, 'https://example.com/us/p/test-product-url-key?selected_product=test-sku-3', 'JSON-LD offer URL does not match');
});

it('should have the correct JSON-LD schema with specialToDate', () => {
config.confMap = {
'/us/p/{{urlkey}}/{{sku}}': {},
};
variations.forEach((variant) => {
variant.specialToDate = '2024-12-31';
});
const html = htmlTemplateFromContext(DEFAULT_CONTEXT({ config }), product, variations).render();
dom = new JSDOM(html);
document = dom.window.document;

const jsonLdScript = document.querySelector('script[type="application/ld+json"]');
const jsonLd = JSON.parse(jsonLdScript.textContent);

jsonLd.offers.forEach((offer) => {
assert.strictEqual(offer.priceValidUntil, '2024-12-31', 'Invalid priceValidUntil for variant');
});
});

it('JSON-LD should contain priceSpecification if variant is on sale', () => {
config.confMap = {
'/us/p/{{urlkey}}/{{sku}}': {},
};
variations.forEach((variant) => {
variant.prices = {
regular: {
amount: 29.99,
currency: 'USD',
maximumAmount: 29.99,
minimumAmount: 29.99,
},
final: {
amount: 14.99,
currency: 'USD',
maximumAmount: 14.99,
minimumAmount: 14.99,
},
};
});
const html = htmlTemplateFromContext(DEFAULT_CONTEXT({ config }), product, variations).render();
dom = new JSDOM(html);
document = dom.window.document;

const jsonLdScript = document.querySelector('script[type="application/ld+json"]');
const jsonLd = JSON.parse(jsonLdScript.textContent);

jsonLd.offers.forEach((offer) => {
assert.strictEqual(offer.priceSpecification['@type'], 'UnitPriceSpecification', 'Invalid ListPrice @type for variant');
assert.strictEqual(offer.priceSpecification.priceType, 'https://schema.org/ListPrice', 'Invalid ListPrice priceType for variant');
assert.strictEqual(offer.priceSpecification.price, 29.99, 'Invalid ListPrice price for variant');
assert.strictEqual(offer.priceSpecification.priceCurrency, 'USD', 'Invalid ListPrice priceCurrency for variant');
});
});

it('should display the correct product name in <h1>', () => {
Expand Down

0 comments on commit e84a1e0

Please sign in to comment.