Skip to content

Commit f91bbae

Browse files
committed
feat: add support for ListPrice and priceValidUntil in JSON-LD
1 parent 583cee9 commit f91bbae

File tree

4 files changed

+133
-41
lines changed

4 files changed

+133
-41
lines changed

src/content/queries/cs-variants.js

+10
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,16 @@ export const adapter = (config, variants) => variants.map(({ selections, product
4949
},
5050
selections: selections ?? [],
5151
};
52+
53+
const specialToDate = product.attributes?.find((attr) => attr.name === 'special_to_date')?.value;
54+
if (specialToDate) {
55+
const today = new Date();
56+
const specialPriceToDate = new Date(specialToDate);
57+
if (specialPriceToDate.getTime() >= today.getTime()) {
58+
variant.specialToDate = specialToDate.substring(0, specialToDate.indexOf(' '));
59+
}
60+
}
61+
5262
if (config.attributeOverrides?.variant) {
5363
Object.entries(config.attributeOverrides.variant).forEach(([key, value]) => {
5464
variant[key] = product.attributes?.find((attr) => attr.name === value)?.value;

src/templates/json/JSONTemplate.js

+55-31
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13+
/* eslint-disable class-methods-use-this */
14+
1315
import { findProductImage, pruneUndefined } from '../../utils/product.js';
1416

1517
export class JSONTemplate {
@@ -80,6 +82,58 @@ export class JSONTemplate {
8082
};
8183
}
8284

85+
renderOffers() {
86+
const image = this.product.images?.[0]?.url
87+
?? findProductImage(this.product, this.variants)?.url;
88+
const configurableProduct = this.variants && this.variants.length > 0;
89+
const offers = configurableProduct ? this.variants : [this.product];
90+
return {
91+
offers: [
92+
...offers.map((v) => {
93+
const offerUrl = this.constructProductURL(configurableProduct ? v : undefined);
94+
const { prices: variantPrices } = v;
95+
const finalPrice = variantPrices?.final?.amount;
96+
const regularPrice = variantPrices?.regular?.amount;
97+
const offer = {
98+
'@type': 'Offer',
99+
sku: v.sku,
100+
url: offerUrl,
101+
image: v.images?.[0]?.url ?? image,
102+
availability: v.inStock ? 'InStock' : 'OutOfStock',
103+
price: finalPrice,
104+
priceCurrency: variantPrices.final?.currency,
105+
};
106+
107+
if (finalPrice < regularPrice) {
108+
offer.priceSpecification = this.renderOffersPriceSpecification(v);
109+
}
110+
111+
if (v.gtin) {
112+
offer.gtin = v.gtin;
113+
}
114+
115+
if (v.specialToDate) {
116+
offer.priceValidUntil = v.specialToDate;
117+
}
118+
119+
return offer;
120+
}).filter(Boolean),
121+
],
122+
};
123+
}
124+
125+
renderOffersPriceSpecification(variant) {
126+
const { prices } = variant;
127+
const { regular } = prices;
128+
const { amount, currency } = regular;
129+
return {
130+
'@type': 'UnitPriceSpecification',
131+
priceType: 'https://schema.org/ListPrice',
132+
price: amount,
133+
priceCurrency: currency,
134+
};
135+
}
136+
83137
render() {
84138
const {
85139
sku,
@@ -88,8 +142,6 @@ export class JSONTemplate {
88142
images,
89143
reviewCount,
90144
ratingValue,
91-
inStock,
92-
prices,
93145
} = this.product;
94146

95147
const productUrl = this.constructProductURL();
@@ -103,35 +155,7 @@ export class JSONTemplate {
103155
description: metaDescription,
104156
image,
105157
productID: sku,
106-
offers: [
107-
prices ? ({
108-
'@type': 'Offer',
109-
sku,
110-
url: productUrl,
111-
image,
112-
availability: inStock ? 'InStock' : 'OutOfStock',
113-
price: prices?.final?.amount,
114-
priceCurrency: prices?.final?.currency,
115-
}) : undefined,
116-
...this.variants.map((v) => {
117-
const offerUrl = this.constructProductURL(v);
118-
const offer = {
119-
'@type': 'Offer',
120-
sku: v.sku,
121-
url: offerUrl,
122-
image: v.images?.[0]?.url ?? image,
123-
availability: v.inStock ? 'InStock' : 'OutOfStock',
124-
price: v.prices?.final?.amount,
125-
priceCurrency: v.prices?.final?.currency,
126-
};
127-
128-
if (v.gtin) {
129-
offer.gtin = v.gtin;
130-
}
131-
132-
return offer;
133-
}).filter(Boolean),
134-
],
158+
...this.renderOffers(),
135159
...(this.renderBrand() ?? {}),
136160
...(typeof reviewCount === 'number'
137161
&& typeof ratingValue === 'number'

src/types.d.ts

+1
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ declare global {
110110
selections: string[];
111111
attributes: Attribute[];
112112
externalId: string;
113+
specialToDate?: string;
113114
gtin?: string;
114115
}
115116

test/templates/html/index.test.js

+67-10
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
import assert from 'node:assert';
1616
import { JSDOM } from 'jsdom';
17-
// import { constructProductUrl } from '../../../src/utils/product.js';
17+
import { JSONTemplate } from '../../../src/templates/json/JSONTemplate.js';
1818
import { DEFAULT_CONTEXT } from '../../fixtures/context.js';
1919
import { createDefaultVariations, createProductVariationFixture } from '../../fixtures/variant.js';
2020
import { createProductFixture } from '../../fixtures/product.js';
@@ -81,26 +81,30 @@ describe('Render Product HTML', () => {
8181
const jsonLdScript = document.querySelector('script[type="application/ld+json"]');
8282
assert.ok(jsonLdScript, 'JSON-LD script tag should exist');
8383

84+
// @ts-ignore
85+
const productTemplate = new JSONTemplate(DEFAULT_CONTEXT({ config }), product, variations);
86+
8487
const jsonLd = JSON.parse(jsonLdScript.textContent);
8588
assert.strictEqual(jsonLd['@type'], 'Product', 'JSON-LD @type should be Product');
86-
// assert.strictEqual(jsonLd['@id'], constructProductUrl(config, product), 'JSON-LD @id does not match product URL');
89+
assert.strictEqual(jsonLd['@id'], productTemplate.constructProductURL(), 'JSON-LD @id does not match product URL');
8790
assert.strictEqual(jsonLd.name, product.name, 'JSON-LD name does not match product name');
8891
assert.strictEqual(jsonLd.sku, product.sku, 'JSON-LD SKU does not match product SKU');
8992
assert.strictEqual(jsonLd.description, product.metaDescription, 'JSON-LD description does not match product description');
9093
assert.strictEqual(jsonLd.image, product.images[0]?.url || '', 'JSON-LD image does not match product image');
9194
assert.strictEqual(jsonLd.productID, product.sku, 'JSON-LD productID does not match product SKU');
9295
assert.ok(Array.isArray(jsonLd.offers), 'JSON-LD offers should be an array');
93-
assert.strictEqual(jsonLd.offers.length, variations.length + 1, 'JSON-LD offers length does not match number of variants');
96+
assert.strictEqual(jsonLd.offers.length, variations.length, 'JSON-LD offers length does not match number of variants');
9497

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

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

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

138-
assert.strictEqual(jsonLd.offers[0].url, 'https://example.com/us/p/test-product-url-key/test-sku', 'JSON-LD offer URL does not match');
139-
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');
140-
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');
141-
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');
142+
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');
143+
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');
144+
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');
145+
});
146+
147+
it('should have the correct JSON-LD schema with specialToDate', () => {
148+
config.confMap = {
149+
'/us/p/{{urlkey}}/{{sku}}': {},
150+
};
151+
variations.forEach((variant) => {
152+
variant.specialToDate = '2024-12-31';
153+
});
154+
const html = htmlTemplateFromContext(DEFAULT_CONTEXT({ config }), product, variations).render();
155+
dom = new JSDOM(html);
156+
document = dom.window.document;
157+
158+
const jsonLdScript = document.querySelector('script[type="application/ld+json"]');
159+
const jsonLd = JSON.parse(jsonLdScript.textContent);
160+
161+
jsonLd.offers.forEach((offer) => {
162+
assert.strictEqual(offer.priceValidUntil, '2024-12-31', 'Invalid priceValidUntil for variant');
163+
});
164+
});
165+
166+
it('JSON-LD should contain priceSpecification if variant is on sale', () => {
167+
config.confMap = {
168+
'/us/p/{{urlkey}}/{{sku}}': {},
169+
};
170+
variations.forEach((variant) => {
171+
variant.prices = {
172+
regular: {
173+
amount: 29.99,
174+
currency: 'USD',
175+
maximumAmount: 29.99,
176+
minimumAmount: 29.99,
177+
},
178+
final: {
179+
amount: 14.99,
180+
currency: 'USD',
181+
maximumAmount: 14.99,
182+
minimumAmount: 14.99,
183+
},
184+
};
185+
});
186+
const html = htmlTemplateFromContext(DEFAULT_CONTEXT({ config }), product, variations).render();
187+
dom = new JSDOM(html);
188+
document = dom.window.document;
189+
190+
const jsonLdScript = document.querySelector('script[type="application/ld+json"]');
191+
const jsonLd = JSON.parse(jsonLdScript.textContent);
192+
193+
jsonLd.offers.forEach((offer) => {
194+
assert.strictEqual(offer.priceSpecification['@type'], 'UnitPriceSpecification', 'Invalid ListPrice @type for variant');
195+
assert.strictEqual(offer.priceSpecification.priceType, 'https://schema.org/ListPrice', 'Invalid ListPrice priceType for variant');
196+
assert.strictEqual(offer.priceSpecification.price, 29.99, 'Invalid ListPrice price for variant');
197+
assert.strictEqual(offer.priceSpecification.priceCurrency, 'USD', 'Invalid ListPrice priceCurrency for variant');
198+
});
142199
});
143200

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

0 commit comments

Comments
 (0)