openvibers/openvibe.coupons Commits
Coupons: a code's own page carries one JSON-LD script, every node in one @graph, and a Product/Offer only where the page shows an amount off with a currency (SEO-10) (#135)
Changes
@@@ -25,8 +25,9 @@ const frame = require('openvibe-shared/frame');const showcase = require('openvibe-shared/showcase');const cache = require('openvibe-shared/cache-policy');const seo = require('openvibe-publishing/seo');--const { renderPage } = require('../render/layout');++const { renderPage, oneGraph } = require('../render/layout');const pages = require('../render/pages');++const offer = require('../render/offer');const home = require('../render/home');const { csrfToken, checkCsrf } = require('../auth/forms');const { ApiError } = require('./errors');@@@ -115,10 +116,16 @@ function createPublicRoutes(ctx) {};function jsonLd(m, v) {-- return [++ const nodes = [seo.structuredData.breadcrumbs([{ name: 'Coupons', url: `${config.baseUrl}/` }, { name: m.name, url: publication.merchantUrl(m) }, ...(v ? [{ name: v.code, url: v.url }] : [])]),seo.structuredData.webPage({ url: v ? v.url : publication.merchantUrl(m), name: v ? `${v.code} — ${v.title}` : `${m.name} coupon codes`, dateModified: v ? v.updated_at : null, inLanguage: 'en' }),];++ // A code's own page carries one JSON-LD script: every node in one @graph. The Product/Offer is++ // there only where the page itself shows a price or an amount off with a currency.++ if (!v) return nodes;++ const product = offer.productOf(v, { merchant: { name: m.name, url: publication.merchantUrl(m) }, now: store.now() });++ if (product) nodes.push(product);++ return oneGraph(nodes);}// ── Home, about ─────────────────────────────────────────@@@ -17,6 +17,15 @@ const layout = require('openvibe-publishing/layout');const frame = require('openvibe-shared/frame');const ovServe = require('openvibe-shared/serve');++/**++ * The page's JSON-LD in one script: every node in one @graph, so a page carries exactly one JSON-LD script.++ * The nodes keep their own fields; only their own @context goes, because the graph carries it once.++ */++const oneGraph = (nodes) => {++ const graph = nodes.filter(Boolean).map((n) => { const c = { ...n }; delete c['@context']; return c; });++ return [{ '@context': 'https://schema.org', '@graph': graph }];++};++const NETWORK_URL = 'https://openvibe.network';const SITE_NAME = 'OpenVibe.Coupons';const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');@@@ -115,4 +124,4 @@ function renderPage(o) {});}--module.exports = { renderPage, asset, assetVersion, setRelease, SITE_NAME, NETWORK_URL };++module.exports = { renderPage, asset, assetVersion, setRelease, SITE_NAME, NETWORK_URL, oneGraph };nnew file mode 100644@@@ -0,0 +1,51 @@++'use strict';++++/**++ * The Product and Offer a code's own page carries — only what the page shows.++ *++ * The owner rule: public structured data may claim only what the page itself shows. So a code's page++ * carries a Product/Offer only when it shows a price or an amount off with a currency:++ * - the price is the money the page's headline shows (an amount off, or a product's price), as a number;++ * a percentage ("20% off"), free shipping or a plain code is not a price, and such a page carries++ * no Product/Offer at all++ * - the currency is the ISO 4217 code the code states, and the page shows it beside the amount++ * - availability comes from the page's own state: in stock while the code is in date and in active++ * results, sold out once the page says it expired++ * - the url is the page's canonical++ * A rating is never invented: a code's page shows no rating and no review count, so it claims none.++ */++const pages = require('./pages');++++const AVAILABILITY = { in_stock: 'https://schema.org/InStock', sold_out: 'https://schema.org/SoldOut' };++++/**++ * The Product/Offer for a code's page, or null when the page shows no price with a currency.++ * v: the code's public view; merchant: { name, url } (the shop the page shows); now: the site's clock.++ */++function productOf(v, { merchant = null, now = Date.now() } = {}) {++ const money = pages.moneyOf(v);++ const currency = pages.currencyOf(v, money);++ if (!money || !currency) return null;++ const expired = v.status === 'expired'++ || (v.expiry && v.expiry.known && v.expiry.expires_at && Date.parse(v.expiry.expires_at) <= now);++ const availability = expired ? AVAILABILITY.sold_out : (v.active ? AVAILABILITY.in_stock : null);++ const offer = {++ '@type': 'Offer',++ price: money.amount,++ priceCurrency: currency,++ url: v.url,++ ...(availability ? { availability } : {}),++ // The expiry the page states, when it states one.++ ...(v.expiry && v.expiry.known ? { validThrough: v.expiry.expires_at } : {}),++ ...(merchant ? { seller: { '@type': 'Organization', name: merchant.name, url: merchant.url } } : {}),++ };++ return {++ '@type': 'Product',++ name: v.title,++ url: v.url,++ ...(v.description ? { description: v.description } : {}),++ offers: [offer],++ };++}++++module.exports = { productOf };@@@ -115,6 +115,40 @@ function headline(v) {return 'Code';}++/**++ * The money the page's headline shows: an amount off ("$15 off") or a product's price ("Air monitor for++ * $35"). A percentage ("20% off"), free shipping or a plain code is not a price — a page that shows++ * none of those carries no Product/Offer at all.++ */++function moneyOf(v) {++ const head = headline(v);++ let m = /^\$(\d[\d,]*(?:\.\d\d)?)\s*off$/.exec(head);++ if (m) return { amount: Number(m[1].replace(/,/g, '')), symbol: '$', kind: 'off' };++ m = /^\$(\d[\d,]*(?:\.\d\d)?)$/.exec(head);++ if (m) return { amount: Number(m[1].replace(/,/g, '')), symbol: '$', kind: 'price' };++ return null;++}++++/** The symbol a currency is written with ("$" for USD, "€" for EUR). */++function symbolOf(currency) {++ try {++ const parts = new Intl.NumberFormat('en', { style: 'currency', currency }).formatToParts(1);++ return (parts.find((p) => p.type === 'currency') || {}).value || null;++ } catch { return null; }++}++++/**++ * The ISO 4217 code the code states for its money: the one its minimum spend records, and only when++ * that is the currency the page's money is written in (a "€20 off" code with a dollar minimum spend++ * is not dollars). A code that states no currency states no currency.++ */++function currencyOf(v, money) {++ if (!money) return null;++ const min = (v.restrictions || []).find((r) => r.kind === 'min_spend' && r.currency);++ if (!min) return null;++ return symbolOf(min.currency) === money.symbol ? min.currency : null;++}++/** The stated restrictions as short chips (none stated: no chips; Details says so in full). */function restrictionChips(list) {if (!list.length) return '';@@@ -140,8 +174,11 @@ function quickReport(v, { viewer, csrf, back }) {/** One code. v: coupons.view(); opts: { viewer, csrf, back, link, page, shopUrl } (page: its own page, where the title is the h1) */function couponCard(v, opts = {}) {const title = opts.link ? h`<a href="/c/${v.id}">${v.title}</a>` : v.title;++ // The currency the code states for its money, shown beside the amount on the code's own page: the page++ // says which currency it is in, and the page's structured data claims nothing more than that.++ const currency = opts.page ? currencyOf(v, moneyOf(v)) : null;return h`<article class="coupon cpn cpn--${v.status}" id="${v.id}">--<div class="cpn-top"><strong class="cpn-off">${headline(v)}</strong>${isProductDeal(v) ? h`<span class="cpn-scope">One product</span>` : ''}</div>++<div class="cpn-top"><strong class="cpn-off">${headline(v)}</strong>${isProductDeal(v) ? h`<span class="cpn-scope">One product</span>` : ''}${currency ? h`<span class="cpn-scope">${currency}</span>` : ''}</div>${opts.page ? h`<h1 class="coupon-title">${title}</h1>` : h`<h3 class="cpn-title">${title}</h3>`}<div class="cpn-code"><code class="code" id="code-${v.id}">${v.code}</code>${k(kit.iconButton({ type: 'button', icon: 'copy', label: `Copy ${v.code}`, attrs: { 'data-ovk-copy': `#code-${v.id}`, hidden: true } }))}${opts.shopUrl ? k(kit.button({ href: opts.shopUrl, label: 'Shop', icon: 'open', kind: 'quiet', small: true, attrs: { rel: 'nofollow noopener', target: '_blank' } })) : ''}</div>${statusLine(v)}@@@ -449,4 +486,4 @@ function message({ heading, text, action }) {}module.exports = {-- shopTab, isProductDeal, headline, home, shopsSection, merchant, couponPage, couponCard, about, submitForm, submitted, connect, companion, COMPANION_RELEASE, watching, staff, message, STATUS_LABEL, SITE_SUMMARY };++ shopTab, isProductDeal, headline, moneyOf, symbolOf, currencyOf, home, shopsSection, merchant, couponPage, couponCard, about, submitForm, submitted, connect, companion, COMPANION_RELEASE, watching, staff, message, STATUS_LABEL, SITE_SUMMARY };@@@ -84,8 +84,11 @@ const { boot, check, done } = require('./helpers/boot');const desc = (page.match(/<meta name="description" content="([^"]*)">/) || [])[1] || '';assert.match(desc, /^Coupon code VEG5 for .+ at .+: (Validity unknown|Reported working|Reported not working), (expires \d{4}-\d{2}-\d{2}|no expiry stated)[^"]*\.$/, desc);assert.ok(desc.length <= 160, desc);-- const blocks = [...page.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));-- assert.deepStrictEqual(blocks.map((b) => b['@type']).sort(), ['BreadcrumbList', 'WebPage']);++ // One JSON-LD script for the page, every node in one @graph (the estate's convention).++ const blocks = [...page.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)];++ assert.strictEqual(blocks.length, 1, 'exactly one JSON-LD script');++ const graph = JSON.parse(blocks[0][1])['@graph'];++ assert.deepStrictEqual(graph.map((b) => b['@type']).sort(), ['BreadcrumbList', 'WebPage']);const json = (await t.get(`/c/${codeId}.json`)).json();assert.strictEqual(json.coupon.code, 'VEG5');assert.strictEqual(json.indexability.indexable, true);nnew file mode 100644@@@ -0,0 +1,98 @@++'use strict';++/**++ * A code's own page carries one JSON-LD script, every node in one @graph, and a Product/Offer only++ * where the page shows a price or an amount off with a currency (the owner rule: structured data may++ * claim only what the page itself shows).++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++const scripts = (html) => [...html.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)];++const nodes = (html) => scripts(html).flatMap((m) => {++ const doc = JSON.parse(m[1]);++ return Array.isArray(doc['@graph']) ? doc['@graph'] : [doc];++});++const product = (html) => nodes(html).find((n) => n['@type'] === 'Product');++++(async () => {++ const t = await boot();++ const alice = t.network.addUser('alice');++ const shop = await t.merchant({ name: 'Green Grocer', host: 'greengrocer.co.uk' });++++ // A code whose page shows "$15 off" and a minimum spend in US dollars.++ const money = await t.submit(alice, {++ merchant_id: shop.id, code: 'SAVE15', title: '$15 off orders of $75+',++ restrictions: { min_spend: { amount: '75.00', currency: 'USD' } },++ });++ assert.strictEqual(money.status, 201, money.text.slice(0, 300));++ const moneyId = money.json().coupon.id;++++ // A code whose page shows only a percentage.++ const percent = await t.submit(alice, { merchant_id: shop.id, code: 'PCT20', title: '20% off sitewide' });++ assert.strictEqual(percent.status, 201, percent.text.slice(0, 300));++ const percentId = percent.json().coupon.id;++++ await check("a code's page carries one JSON-LD script, every node in one @graph", async () => {++ const page = await t.get(`/c/${moneyId}`);++ assert.strictEqual(page.status, 200);++ assert.strictEqual(scripts(page.text).length, 1, 'exactly one JSON-LD script');++ const doc = JSON.parse(scripts(page.text)[0][1]);++ assert.strictEqual(doc['@context'], 'https://schema.org');++ assert.ok(Array.isArray(doc['@graph']), 'the nodes are in one @graph');++ const types = doc['@graph'].map((n) => n['@type']);++ assert.ok(types.includes('BreadcrumbList') && types.includes('WebPage') && types.includes('Product'), JSON.stringify(types));++ for (const n of doc['@graph']) assert.strictEqual(n['@context'], undefined, 'the graph carries the @context once');++ });++++ await check('the offer claims the money the page shows, as a number', async () => {++ const page = await t.get(`/c/${moneyId}`);++ assert.match(page.text, /\$15 off/, 'the page shows the amount');++ assert.match(page.text, /<span class="cpn-scope">USD<\/span>/, 'the page shows the currency it claims');++ const p = product(page.text);++ assert.ok(p, 'the page carries a Product');++ assert.strictEqual(p.url, `https://openvibe.coupons/c/${moneyId}`);++ assert.strictEqual(p.offers.length, 1);++ const offer = p.offers[0];++ assert.strictEqual(typeof offer.price, 'number', 'the price is a number');++ assert.strictEqual(offer.price, 15);++ assert.strictEqual(offer.priceCurrency, 'USD');++ assert.strictEqual(offer.url, `https://openvibe.coupons/c/${moneyId}`);++ assert.strictEqual(offer.availability, 'https://schema.org/InStock');++ });++++ await check('a page that shows only a percentage carries no Product and no Offer', async () => {++ const page = await t.get(`/c/${percentId}`);++ assert.strictEqual(scripts(page.text).length, 1, 'exactly one JSON-LD script');++ assert.ok(!product(page.text), 'no Product');++ assert.ok(!/"price"/.test(JSON.stringify(nodes(page.text))), 'no price at all');++ });++++ await check('a page shows no rating, so it claims none', async () => {++ const page = await t.get(`/c/${moneyId}`);++ assert.ok(!nodes(page.text).some((n) => n['@type'] === 'AggregateRating'), 'no invented rating');++ });++++ await check('a shop page keeps the nodes it already had', async () => {++ const page = await t.get(`/m/${shop.slug}`);++ assert.strictEqual(page.status, 200);++ const types = nodes(page.text).map((n) => n['@type']);++ assert.ok(types.includes('BreadcrumbList') && types.includes('WebPage'), JSON.stringify(types));++ assert.ok(!types.includes('Product'), 'no Offer for a code the shop page does not offer as a product');++ });++++ await check('an expired code says sold out', async () => {++ const r = await t.submit(alice, {++ merchant_id: shop.id, code: 'OLD20', title: '$20 off', expires: '2026-09-23',++ restrictions: { min_spend: { amount: '50.00', currency: 'USD' } },++ });++ assert.strictEqual(r.status, 201, r.text.slice(0, 300));++ t.clock.advance(2 * 24 * 3600 * 1000); // past its expiry++ const page = await t.get(`/c/${r.json().coupon.id}`);++ assert.strictEqual(page.status, 200);++ const p = product(page.text);++ assert.ok(p, 'the page still carries the Product it shows');++ assert.strictEqual(p.offers[0].availability, 'https://schema.org/SoldOut');++ });++++ await done();++})().catch((e) => { console.error(e); process.exit(1); });
@@ -25,8 +25,9 @@ const frame = require('openvibe-shared/frame');
@@ -25,8 +25,9 @@ const frame = require('openvibe-shared/frame');
const showcase = require('openvibe-shared/showcase');
const showcase = require('openvibe-shared/showcase');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
const seo = require('openvibe-publishing/seo');
const seo = require('openvibe-publishing/seo');
-const { renderPage } = require('../render/layout');
+const { renderPage, oneGraph } = require('../render/layout');
const pages = require('../render/pages');
const pages = require('../render/pages');
+const offer = require('../render/offer');
const home = require('../render/home');
const home = require('../render/home');
const { csrfToken, checkCsrf } = require('../auth/forms');
const { csrfToken, checkCsrf } = require('../auth/forms');
const { ApiError } = require('./errors');
const { ApiError } = require('./errors');
@@ -115,10 +116,16 @@ function createPublicRoutes(ctx) {
@@ -115,10 +116,16 @@ function createPublicRoutes(ctx) {
};
};
function jsonLd(m, v) {
function jsonLd(m, v) {
- return [
+ const nodes = [
seo.structuredData.breadcrumbs([{ name: 'Coupons', url: `${config.baseUrl}/` }, { name: m.name, url: publication.merchantUrl(m) }, ...(v ? [{ name: v.code, url: v.url }] : [])]),
seo.structuredData.breadcrumbs([{ name: 'Coupons', url: `${config.baseUrl}/` }, { name: m.name, url: publication.merchantUrl(m) }, ...(v ? [{ name: v.code, url: v.url }] : [])]),
seo.structuredData.webPage({ url: v ? v.url : publication.merchantUrl(m), name: v ? `${v.code} — ${v.title}` : `${m.name} coupon codes`, dateModified: v ? v.updated_at : null, inLanguage: 'en' }),
seo.structuredData.webPage({ url: v ? v.url : publication.merchantUrl(m), name: v ? `${v.code} — ${v.title}` : `${m.name} coupon codes`, dateModified: v ? v.updated_at : null, inLanguage: 'en' }),
];
];
+ // A code's own page carries one JSON-LD script: every node in one @graph. The Product/Offer is
+ // there only where the page itself shows a price or an amount off with a currency.
+ if (!v) return nodes;
+ const product = offer.productOf(v, { merchant: { name: m.name, url: publication.merchantUrl(m) }, now: store.now() });
+ if (product) nodes.push(product);
+ return oneGraph(nodes);
}
}
// ── Home, about ─────────────────────────────────────────
// ── Home, about ─────────────────────────────────────────
@@ -17,6 +17,15 @@ const layout = require('openvibe-publishing/layout');
@@ -17,6 +17,15 @@ const layout = require('openvibe-publishing/layout');
const frame = require('openvibe-shared/frame');
const frame = require('openvibe-shared/frame');
const ovServe = require('openvibe-shared/serve');
const ovServe = require('openvibe-shared/serve');
+/**
+ * The page's JSON-LD in one script: every node in one @graph, so a page carries exactly one JSON-LD script.
+ * The nodes keep their own fields; only their own @context goes, because the graph carries it once.
+ */
+const oneGraph = (nodes) => {
+ const graph = nodes.filter(Boolean).map((n) => { const c = { ...n }; delete c['@context']; return c; });
+ return [{ '@context': 'https://schema.org', '@graph': graph }];
+};
+
const NETWORK_URL = 'https://openvibe.network';
const NETWORK_URL = 'https://openvibe.network';
const SITE_NAME = 'OpenVibe.Coupons';
const SITE_NAME = 'OpenVibe.Coupons';
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
@@ -115,4 +124,4 @@ function renderPage(o) {
@@ -115,4 +124,4 @@ function renderPage(o) {
});
});
}
}
-module.exports = { renderPage, asset, assetVersion, setRelease, SITE_NAME, NETWORK_URL };
+module.exports = { renderPage, asset, assetVersion, setRelease, SITE_NAME, NETWORK_URL, oneGraph };
new file mode 100644
new file mode 100644
@@ -0,0 +1,51 @@
@@ -0,0 +1,51 @@
+'use strict';
+
+/**
+ * The Product and Offer a code's own page carries — only what the page shows.
+ *
+ * The owner rule: public structured data may claim only what the page itself shows. So a code's page
+ * carries a Product/Offer only when it shows a price or an amount off with a currency:
+ * - the price is the money the page's headline shows (an amount off, or a product's price), as a number;
+ * a percentage ("20% off"), free shipping or a plain code is not a price, and such a page carries
+ * no Product/Offer at all
+ * - the currency is the ISO 4217 code the code states, and the page shows it beside the amount
+ * - availability comes from the page's own state: in stock while the code is in date and in active
+ * results, sold out once the page says it expired
+ * - the url is the page's canonical
+ * A rating is never invented: a code's page shows no rating and no review count, so it claims none.
+ */
+const pages = require('./pages');
+
+const AVAILABILITY = { in_stock: 'https://schema.org/InStock', sold_out: 'https://schema.org/SoldOut' };
+
+/**
+ * The Product/Offer for a code's page, or null when the page shows no price with a currency.
+ * v: the code's public view; merchant: { name, url } (the shop the page shows); now: the site's clock.
+ */
+function productOf(v, { merchant = null, now = Date.now() } = {}) {
+ const money = pages.moneyOf(v);
+ const currency = pages.currencyOf(v, money);
+ if (!money || !currency) return null;
+ const expired = v.status === 'expired'
+ || (v.expiry && v.expiry.known && v.expiry.expires_at && Date.parse(v.expiry.expires_at) <= now);
+ const availability = expired ? AVAILABILITY.sold_out : (v.active ? AVAILABILITY.in_stock : null);
+ const offer = {
+ '@type': 'Offer',
+ price: money.amount,
+ priceCurrency: currency,
+ url: v.url,
+ ...(availability ? { availability } : {}),
+ // The expiry the page states, when it states one.
+ ...(v.expiry && v.expiry.known ? { validThrough: v.expiry.expires_at } : {}),
+ ...(merchant ? { seller: { '@type': 'Organization', name: merchant.name, url: merchant.url } } : {}),
+ };
+ return {
+ '@type': 'Product',
+ name: v.title,
+ url: v.url,
+ ...(v.description ? { description: v.description } : {}),
+ offers: [offer],
+ };
+}
+
+module.exports = { productOf };
@@ -115,6 +115,40 @@ function headline(v) {
@@ -115,6 +115,40 @@ function headline(v) {
return 'Code';
return 'Code';
}
}
+/**
+ * The money the page's headline shows: an amount off ("$15 off") or a product's price ("Air monitor for
+ * $35"). A percentage ("20% off"), free shipping or a plain code is not a price — a page that shows
+ * none of those carries no Product/Offer at all.
+ */
+function moneyOf(v) {
+ const head = headline(v);
+ let m = /^\$(\d[\d,]*(?:\.\d\d)?)\s*off$/.exec(head);
+ if (m) return { amount: Number(m[1].replace(/,/g, '')), symbol: '$', kind: 'off' };
+ m = /^\$(\d[\d,]*(?:\.\d\d)?)$/.exec(head);
+ if (m) return { amount: Number(m[1].replace(/,/g, '')), symbol: '$', kind: 'price' };
+ return null;
+}
+
+/** The symbol a currency is written with ("$" for USD, "€" for EUR). */
+function symbolOf(currency) {
+ try {
+ const parts = new Intl.NumberFormat('en', { style: 'currency', currency }).formatToParts(1);
+ return (parts.find((p) => p.type === 'currency') || {}).value || null;
+ } catch { return null; }
+}
+
+/**
+ * The ISO 4217 code the code states for its money: the one its minimum spend records, and only when
+ * that is the currency the page's money is written in (a "€20 off" code with a dollar minimum spend
+ * is not dollars). A code that states no currency states no currency.
+ */
+function currencyOf(v, money) {
+ if (!money) return null;
+ const min = (v.restrictions || []).find((r) => r.kind === 'min_spend' && r.currency);
+ if (!min) return null;
+ return symbolOf(min.currency) === money.symbol ? min.currency : null;
+}
+
/** The stated restrictions as short chips (none stated: no chips; Details says so in full). */
/** The stated restrictions as short chips (none stated: no chips; Details says so in full). */
function restrictionChips(list) {
function restrictionChips(list) {
if (!list.length) return '';
if (!list.length) return '';
@@ -140,8 +174,11 @@ function quickReport(v, { viewer, csrf, back }) {
@@ -140,8 +174,11 @@ function quickReport(v, { viewer, csrf, back }) {
/** One code. v: coupons.view(); opts: { viewer, csrf, back, link, page, shopUrl } (page: its own page, where the title is the h1) */
/** One code. v: coupons.view(); opts: { viewer, csrf, back, link, page, shopUrl } (page: its own page, where the title is the h1) */
function couponCard(v, opts = {}) {
function couponCard(v, opts = {}) {
const title = opts.link ? h`<a href="/c/${v.id}">${v.title}</a>` : v.title;
const title = opts.link ? h`<a href="/c/${v.id}">${v.title}</a>` : v.title;
+ // The currency the code states for its money, shown beside the amount on the code's own page: the page
+ // says which currency it is in, and the page's structured data claims nothing more than that.
+ const currency = opts.page ? currencyOf(v, moneyOf(v)) : null;
return h`<article class="coupon cpn cpn--${v.status}" id="${v.id}">
return h`<article class="coupon cpn cpn--${v.status}" id="${v.id}">
-<div class="cpn-top"><strong class="cpn-off">${headline(v)}</strong>${isProductDeal(v) ? h`<span class="cpn-scope">One product</span>` : ''}</div>
+<div class="cpn-top"><strong class="cpn-off">${headline(v)}</strong>${isProductDeal(v) ? h`<span class="cpn-scope">One product</span>` : ''}${currency ? h`<span class="cpn-scope">${currency}</span>` : ''}</div>
${opts.page ? h`<h1 class="coupon-title">${title}</h1>` : h`<h3 class="cpn-title">${title}</h3>`}
${opts.page ? h`<h1 class="coupon-title">${title}</h1>` : h`<h3 class="cpn-title">${title}</h3>`}
<div class="cpn-code"><code class="code" id="code-${v.id}">${v.code}</code>${k(kit.iconButton({ type: 'button', icon: 'copy', label: `Copy ${v.code}`, attrs: { 'data-ovk-copy': `#code-${v.id}`, hidden: true } }))}${opts.shopUrl ? k(kit.button({ href: opts.shopUrl, label: 'Shop', icon: 'open', kind: 'quiet', small: true, attrs: { rel: 'nofollow noopener', target: '_blank' } })) : ''}</div>
<div class="cpn-code"><code class="code" id="code-${v.id}">${v.code}</code>${k(kit.iconButton({ type: 'button', icon: 'copy', label: `Copy ${v.code}`, attrs: { 'data-ovk-copy': `#code-${v.id}`, hidden: true } }))}${opts.shopUrl ? k(kit.button({ href: opts.shopUrl, label: 'Shop', icon: 'open', kind: 'quiet', small: true, attrs: { rel: 'nofollow noopener', target: '_blank' } })) : ''}</div>
${statusLine(v)}
${statusLine(v)}
@@ -449,4 +486,4 @@ function message({ heading, text, action }) {
@@ -449,4 +486,4 @@ function message({ heading, text, action }) {
}
}
module.exports = {
module.exports = {
- shopTab, isProductDeal, headline, home, shopsSection, merchant, couponPage, couponCard, about, submitForm, submitted, connect, companion, COMPANION_RELEASE, watching, staff, message, STATUS_LABEL, SITE_SUMMARY };
+ shopTab, isProductDeal, headline, moneyOf, symbolOf, currencyOf, home, shopsSection, merchant, couponPage, couponCard, about, submitForm, submitted, connect, companion, COMPANION_RELEASE, watching, staff, message, STATUS_LABEL, SITE_SUMMARY };
@@ -84,8 +84,11 @@ const { boot, check, done } = require('./helpers/boot');
@@ -84,8 +84,11 @@ const { boot, check, done } = require('./helpers/boot');
const desc = (page.match(/<meta name="description" content="([^"]*)">/) || [])[1] || '';
const desc = (page.match(/<meta name="description" content="([^"]*)">/) || [])[1] || '';
assert.match(desc, /^Coupon code VEG5 for .+ at .+: (Validity unknown|Reported working|Reported not working), (expires \d{4}-\d{2}-\d{2}|no expiry stated)[^"]*\.$/, desc);
assert.match(desc, /^Coupon code VEG5 for .+ at .+: (Validity unknown|Reported working|Reported not working), (expires \d{4}-\d{2}-\d{2}|no expiry stated)[^"]*\.$/, desc);
assert.ok(desc.length <= 160, desc);
assert.ok(desc.length <= 160, desc);
- const blocks = [...page.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
- assert.deepStrictEqual(blocks.map((b) => b['@type']).sort(), ['BreadcrumbList', 'WebPage']);
+ // One JSON-LD script for the page, every node in one @graph (the estate's convention).
+ const blocks = [...page.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)];
+ assert.strictEqual(blocks.length, 1, 'exactly one JSON-LD script');
+ const graph = JSON.parse(blocks[0][1])['@graph'];
+ assert.deepStrictEqual(graph.map((b) => b['@type']).sort(), ['BreadcrumbList', 'WebPage']);
const json = (await t.get(`/c/${codeId}.json`)).json();
const json = (await t.get(`/c/${codeId}.json`)).json();
assert.strictEqual(json.coupon.code, 'VEG5');
assert.strictEqual(json.coupon.code, 'VEG5');
assert.strictEqual(json.indexability.indexable, true);
assert.strictEqual(json.indexability.indexable, true);
new file mode 100644
new file mode 100644
@@ -0,0 +1,98 @@
@@ -0,0 +1,98 @@
+'use strict';
+/**
+ * A code's own page carries one JSON-LD script, every node in one @graph, and a Product/Offer only
+ * where the page shows a price or an amount off with a currency (the owner rule: structured data may
+ * claim only what the page itself shows).
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+const scripts = (html) => [...html.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)];
+const nodes = (html) => scripts(html).flatMap((m) => {
+ const doc = JSON.parse(m[1]);
+ return Array.isArray(doc['@graph']) ? doc['@graph'] : [doc];
+});
+const product = (html) => nodes(html).find((n) => n['@type'] === 'Product');
+
+(async () => {
+ const t = await boot();
+ const alice = t.network.addUser('alice');
+ const shop = await t.merchant({ name: 'Green Grocer', host: 'greengrocer.co.uk' });
+
+ // A code whose page shows "$15 off" and a minimum spend in US dollars.
+ const money = await t.submit(alice, {
+ merchant_id: shop.id, code: 'SAVE15', title: '$15 off orders of $75+',
+ restrictions: { min_spend: { amount: '75.00', currency: 'USD' } },
+ });
+ assert.strictEqual(money.status, 201, money.text.slice(0, 300));
+ const moneyId = money.json().coupon.id;
+
+ // A code whose page shows only a percentage.
+ const percent = await t.submit(alice, { merchant_id: shop.id, code: 'PCT20', title: '20% off sitewide' });
+ assert.strictEqual(percent.status, 201, percent.text.slice(0, 300));
+ const percentId = percent.json().coupon.id;
+
+ await check("a code's page carries one JSON-LD script, every node in one @graph", async () => {
+ const page = await t.get(`/c/${moneyId}`);
+ assert.strictEqual(page.status, 200);
+ assert.strictEqual(scripts(page.text).length, 1, 'exactly one JSON-LD script');
+ const doc = JSON.parse(scripts(page.text)[0][1]);
+ assert.strictEqual(doc['@context'], 'https://schema.org');
+ assert.ok(Array.isArray(doc['@graph']), 'the nodes are in one @graph');
+ const types = doc['@graph'].map((n) => n['@type']);
+ assert.ok(types.includes('BreadcrumbList') && types.includes('WebPage') && types.includes('Product'), JSON.stringify(types));
+ for (const n of doc['@graph']) assert.strictEqual(n['@context'], undefined, 'the graph carries the @context once');
+ });
+
+ await check('the offer claims the money the page shows, as a number', async () => {
+ const page = await t.get(`/c/${moneyId}`);
+ assert.match(page.text, /\$15 off/, 'the page shows the amount');
+ assert.match(page.text, /<span class="cpn-scope">USD<\/span>/, 'the page shows the currency it claims');
+ const p = product(page.text);
+ assert.ok(p, 'the page carries a Product');
+ assert.strictEqual(p.url, `https://openvibe.coupons/c/${moneyId}`);
+ assert.strictEqual(p.offers.length, 1);
+ const offer = p.offers[0];
+ assert.strictEqual(typeof offer.price, 'number', 'the price is a number');
+ assert.strictEqual(offer.price, 15);
+ assert.strictEqual(offer.priceCurrency, 'USD');
+ assert.strictEqual(offer.url, `https://openvibe.coupons/c/${moneyId}`);
+ assert.strictEqual(offer.availability, 'https://schema.org/InStock');
+ });
+
+ await check('a page that shows only a percentage carries no Product and no Offer', async () => {
+ const page = await t.get(`/c/${percentId}`);
+ assert.strictEqual(scripts(page.text).length, 1, 'exactly one JSON-LD script');
+ assert.ok(!product(page.text), 'no Product');
+ assert.ok(!/"price"/.test(JSON.stringify(nodes(page.text))), 'no price at all');
+ });
+
+ await check('a page shows no rating, so it claims none', async () => {
+ const page = await t.get(`/c/${moneyId}`);
+ assert.ok(!nodes(page.text).some((n) => n['@type'] === 'AggregateRating'), 'no invented rating');
+ });
+
+ await check('a shop page keeps the nodes it already had', async () => {
+ const page = await t.get(`/m/${shop.slug}`);
+ assert.strictEqual(page.status, 200);
+ const types = nodes(page.text).map((n) => n['@type']);
+ assert.ok(types.includes('BreadcrumbList') && types.includes('WebPage'), JSON.stringify(types));
+ assert.ok(!types.includes('Product'), 'no Offer for a code the shop page does not offer as a product');
+ });
+
+ await check('an expired code says sold out', async () => {
+ const r = await t.submit(alice, {
+ merchant_id: shop.id, code: 'OLD20', title: '$20 off', expires: '2026-09-23',
+ restrictions: { min_spend: { amount: '50.00', currency: 'USD' } },
+ });
+ assert.strictEqual(r.status, 201, r.text.slice(0, 300));
+ t.clock.advance(2 * 24 * 3600 * 1000); // past its expiry
+ const page = await t.get(`/c/${r.json().coupon.id}`);
+ assert.strictEqual(page.status, 200);
+ const p = product(page.text);
+ assert.ok(p, 'the page still carries the Product it shows');
+ assert.strictEqual(p.offers[0].availability, 'https://schema.org/SoldOut');
+ });
+
+ await done();
+})().catch((e) => { console.error(e); process.exit(1); });