openvibers/openvibe.host Commits

Host: every plan row shows its term and renewal, and every outbound link what it earns us (#280)

OP OpenVibers committed 9 days ago · 7d685131e9 · 7 files, +139 −41

Market outcomes 1, 2 and 7 now render on the home, provider, plan and comparison pages, not only on a plan page: a
monthly, 1-year or 3-year term (or "term not published"), the stated renewal (or "Renewal price not published"), and
a commission statement on every outbound link. Home plan cards and the cheapest-servers rail carry the same lines.

Outcome 8 is finished everywhere: a zero or missing price, and a per-GB storage rate, no longer divide into $0.00 per
resource. The per-resource ratios stay Unknown, the plan trend and JSON-LD carry no price, and the Search document names
none.

Changes

ddiff --git a/server/catalog/search-index.js b/server/catalog/search-index.js
iindex 4935c69..18bf7a6 100644
---- a/server/catalog/search-index.js
++++ b/server/catalog/search-index.js
@@@ -26,9 +26,10 @@ const day = (ms) => (Number.isFinite(Number(ms)) ? new Date(Number(ms)).toISOStr
const iso = (ms) => (Number.isFinite(Number(ms)) && ms != null ? new Date(Number(ms)).toISOString() : null);
function planDocument(row) {
-- // A per-GB storage rate is not a monthly price: the document names it as the rate per GB it is.
++ // A per-GB storage rate is not a monthly price: the document names it as the rate per GB it is. A zero or
++ // missing row has no price to name, so the document says nothing rather than "$0.00 a month" (outcome 8).
const storage = isStorageRate(row);
-- const price = money(row.price_monthly, row.currency, storage ? 4 : 2);
++ const price = Number(row.price_monthly) > 0 ? money(row.price_monthly, row.currency, storage ? 4 : 2) : null;
const priceText = price ? (storage ? `${price} a GB a month` : `${price} a month`) : null;
const transfer = row.unmetered ? 'unmetered transfer' : (sizeGb(row.bandwidth_gb) ? `${sizeGb(row.bandwidth_gb)} transfer` : null);
const specs = [row.vcpu != null ? `${Number(row.vcpu)} vCPU` : null, bytesMb(row.ram_mb) ? `${bytesMb(row.ram_mb)} RAM` : null,
ddiff --git a/server/catalog/store.js b/server/catalog/store.js
iindex b74c9e9..0360c1b 100644
---- a/server/catalog/store.js
++++ b/server/catalog/store.js
@@@ -207,7 +207,12 @@ function createCatalog({ store, community = null, log = console, indexnow = null
const rows = await db.prepare(`SELECT * FROM catalog_offers WHERE ${where} ORDER BY ${OFFER_SORTS[sort]} LIMIT 51 OFFSET ${offset}`).all(...args);
return { offers: rows.slice(0, 50).map(offerOut), next_cursor: rows.length > 50 ? String(offset + 50) : null };
}
-- const planOut = (p) => ({ ...p, affiliate_rel: p.affiliate_url ? 'sponsored nofollow noopener' : null, affiliate_disclosure: p.affiliate_url ? 'OpenVibe may earn a commission from links marked as affiliate links; it never changes the order or the prices shown.' : null, price_per_vcpu: p.vcpu > 0 ? Number(p.price_monthly) / Number(p.vcpu) : null, price_per_gb_ram: p.ram_mb > 0 ? Number(p.price_monthly) * 1024 / Number(p.ram_mb) : null, price_per_gb_disk: p.disk_gb > 0 ? Number(p.price_monthly) / Number(p.disk_gb) : null, checked_at: new Date(Number(p.checked_at)).toISOString(), stale: isStale(p.checked_at, store.now()), price_unit: isStorageRate(p) ? 'per_gb_month' : 'month' });
++ // A per-resource ratio divides a monthly server price, so a zero, missing or per-GB storage rate has no ratio:
++ // it stays null and the page says Unknown, never $0.00 (market outcome 8, server/catalog/pricing.js).
++ const planOut = (p) => {
++ const priced = Number(p.price_monthly) > 0 && !isStorageRate(p);
++ return { ...p, affiliate_rel: p.affiliate_url ? 'sponsored nofollow noopener' : null, affiliate_disclosure: p.affiliate_url ? 'OpenVibe may earn a commission from links marked as affiliate links; it never changes the order or the prices shown.' : null, price_per_vcpu: priced && p.vcpu > 0 ? Number(p.price_monthly) / Number(p.vcpu) : null, price_per_gb_ram: priced && p.ram_mb > 0 ? Number(p.price_monthly) * 1024 / Number(p.ram_mb) : null, price_per_gb_disk: priced && p.disk_gb > 0 ? Number(p.price_monthly) / Number(p.disk_gb) : null, checked_at: new Date(Number(p.checked_at)).toISOString(), stale: isStale(p.checked_at, store.now()), price_unit: isStorageRate(p) ? 'per_gb_month' : 'month' };
++ };
async function plans(query = {}) {
const allowed = new Set(['kind', 'min_vcpu', 'min_ram_mb', 'min_disk_gb', 'max_price', 'currency', 'location', 'provider', 'sort', 'cursor']);
for (const k of Object.keys(query)) if (!allowed.has(k) || Array.isArray(query[k])) invalid(`Invalid filter: ${k}`);
ddiff --git a/server/http/catalog-views.js b/server/http/catalog-views.js
iindex 24add4d..a38ebcb 100644
---- a/server/http/catalog-views.js
++++ b/server/http/catalog-views.js
@@@ -16,6 +16,7 @@ const ovServe = require('openvibe-shared/serve');
const render = require('../render/catalog');
const renderDomains = require('../render/domains');
const renderBench = require('../render/benchmarks');
++const { isStorageRate } = require('../catalog/pricing');
// The home's questions are Host's own FAQ in its service manifest (openvibe-contracts), shown and published as FAQPage.
const HOST_FAQ = (((contracts.services.manifests.find((m) => m.id === 'host') || {}).site || {}).faq || []);
@@@ -111,12 +112,18 @@ function createCatalogViews({ catalog, benchmarks, community, config, log = cons
const p = await catalog.plan(id);
const [discussion, bench] = await Promise.all([discussionFor(ctx, 'plan', p, p.provider_name + ' ' + p.name), benchmarks.summary({ planId: p.id })]);
const benchHtml = renderBench.summaryBlock(bench, { href: '/catalog/benchmarks?plan=' + encodeURIComponent(p.id), label: p.provider_name + ' ' + p.name });
++ // The description repeats the page's price only when the row states a monthly price; a per-GB rate or an
++ // unpublished price is named as such, never as "$0.00 a month" (market outcome 8).
++ const price = Number(p.price_monthly);
++ const priceText = price > 0
++ ? (isStorageRate(p) ? `${render.money(price, p.currency, 4)} a GB a month` : `${render.money(price, p.currency)} a month`)
++ : 'price not published';
return {
title: p.provider_name + ' ' + p.name,
body: render.plan(p, discussion, { baseUrl, watchUrl, extra: benchHtml }),
styles: ['kit.css', 'kit-data.css'], scripts: ['kit-data-web.js'],
indexable: true,
-- description: p.provider_name + ' ' + p.name + ': ' + p.currency + ' ' + p.price_monthly + ' a month, with its specifications, price per resource and price history.',
++ description: p.provider_name + ' ' + p.name + ': ' + priceText + ', with its specifications, price per resource and price history.',
jsonLd: render.jsonLd.plan(baseUrl, p),
};
}
ddiff --git a/server/render/catalog.js b/server/render/catalog.js
iindex c80ba7a..67ef682 100644
---- a/server/render/catalog.js
++++ b/server/render/catalog.js
@@@ -54,40 +54,44 @@ const checkedText = (p, now) => `${esc(day(p.checked_at) || 'unknown')}${staleMa
const or = (v) => (v == null ? UNKNOWN : esc(v));
const kindName = (k) => (KINDS.find(([v]) => v === k) || [k, k])[1];
const link = (href, label, cls = '') => `<a href="${esc(href)}"${cls ? ` class="${cls}"` : ''}>${label}</a>`;
--const out = (href, label, { affiliate = false, cls = '' } = {}) => `<a href="${esc(href)}"${cls ? ` class="${cls}"` : ''} rel="${affiliate ? 'sponsored nofollow noopener' : 'noopener'}">${label}</a>`;
++// Market outcome 7: every outbound link says what it earns us, in the page's own words. A link with no affiliate
++// deal says so; an affiliate link says we earn a commission and keeps the provider's own note beside it.
++const EARN_AFFILIATE = 'We earn a commission on this link.';
++const EARN_NONE = 'No commission.';
++const out = (href, label, { affiliate = false, cls = '', rel = null } = {}) => `<a href="${esc(href)}"${cls ? ` class="${cls}"` : ''} rel="${rel || (affiliate ? 'sponsored nofollow noopener' : 'noopener')}">${label}</a><small class="hc-note">${affiliate ? EARN_AFFILIATE : EARN_NONE}</small>`;
/** The visit button for a provider: its affiliate link (marked, with the provider's own disclosure) or its homepage. */
function visit(p, label = 'Visit provider') {
if (p.affiliate_url) {
return `<span class="hc-visit">${out(p.affiliate_url, `${esc(label)} <span class="hc-chip">Affiliate</span>`, { affiliate: true, cls: 'button' })}${p.affiliate_note ? `<small class="hc-note">${esc(p.affiliate_note)}</small>` : ''}</span>`;
}
-- // What a link earns us, said on every link (market outcome 7): an affiliate link is marked above; this one earns nothing.
-- return p.homepage_url ? `<span class="hc-visit">${out(p.homepage_url, esc(label), { cls: 'button hc-button-quiet' })}<small class="hc-note">No commission: we earn nothing from this link.</small></span>` : '';
++ return p.homepage_url ? `<span class="hc-visit">${out(p.homepage_url, esc(label), { cls: 'button hc-button-quiet' })}</span>` : '';
}
/**
* The term a price is quoted for, and what it renews at (market outcomes 1 and 2, migration 0014). A sourced plan's
-- * price is the provider's own monthly list price; a staff entry states its term or leaves it unknown, and an unknown
-- * renewal price is said to be unknown, never assumed.
++ * price is the provider's own monthly list price; a staff entry states its term or leaves it unknown. The renewal
++ * price is shown only when the row states one; otherwise the page says it is not published and never assumes the
++ * current price carries over.
*/
--const TERMS = { monthly: 'Month to month', '1-year': '1-year term', '3-year': '3-year term' };
++const TERMS = { monthly: 'Monthly', '1-year': '1-year term', '3-year': '3-year term' };
++const termText = (p) => TERMS[p.price_term] || 'term not published';
++const renewalStated = (p) => p.renewal_price != null && Number.isFinite(Number(p.renewal_price)) && Number(p.renewal_price) > 0;
++const renewText = (p) => (renewalStated(p) ? `Renews at ${money(Number(p.renewal_price), p.currency)} a month` : 'Renewal price not published');
function termLines(p) {
-- if (isStorageRate(p)) return '';
-- const term = TERMS[p.price_term];
-- if (!term) return '<p class="hc-dim">Term not published.</p>';
-- const renews = p.renewal_price != null && Number.isFinite(Number(p.renewal_price))
-- ? `Renews at ${esc(money(Number(p.renewal_price), p.currency))} a month.`
-- : p.price_term === 'monthly' ? 'At the provider\'s list price.' : 'Renewal price not published.';
-- return `<p class="hc-dim">${esc(term)}. ${renews}</p>`;
++ const term = TERMS[p.price_term] ? `${TERMS[p.price_term]}.` : 'Term not published.';
++ const renews = renewalStated(p) ? `Renews at ${esc(money(Number(p.renewal_price), p.currency))} a month.` : 'Renewal price not published.';
++ return `<p class="hc-dim">${esc(term)} ${renews}</p>`;
}
function priceCell(p) {
const price = Number(p.price_monthly);
-- // A per-GB rate is not a monthly price: it is shown as the rate per GB it is (server/catalog/pricing.js).
-- if (isStorageRate(p)) return `<span class="hc-price">${Number.isFinite(price) ? esc(money(price, p.currency, 4)) : 'price not stated'}</span><span class="hc-per">/GB/month</span>`;
-- const m = Number.isFinite(price) && price > 0 ? money(price, p.currency) : null;
-- const term = p.price_term === 'monthly' ? '' : TERMS[p.price_term] ? `<span class="hc-term">${esc(TERMS[p.price_term])}</span>` : '<span class="hc-term">term not published</span>';
-- return `<span class="hc-price">${m ? esc(m) : 'price not stated'}</span><span class="hc-per">/mo</span>${term}`;
++ const storage = isStorageRate(p);
++ // A per-GB rate is not a monthly price: it is shown as the rate per GB it is (server/catalog/pricing.js). A zero
++ // or missing row is "price not stated", never $0.00 (market outcome 8).
++ const stated = Number.isFinite(price) && price > 0;
++ const main = storage ? (stated ? money(price, p.currency, 4) : 'price not stated') : (stated ? money(price, p.currency) : 'price not stated');
++ return `<span class="hc-price">${esc(main)}</span><span class="hc-per">${storage ? '/GB/month' : '/mo'}</span><span class="hc-term">${esc(termText(p))}</span><span class="hc-term">${esc(renewText(p))}</span>`;
}
const isoAt = (v) => new Date(v).toISOString();
@@@ -206,7 +210,7 @@ function method(hasAffiliate) {
<li><strong>Sourced.</strong> Plans come from providers' official APIs through OpenVibe.Sources, or from a staff entry that names the page it was copied from. Nothing is estimated.</li>
<li><strong>Dated.</strong> Every price shows the day it was last checked and links its source. Prices change; the provider's own page is the final word.</li>
<li><strong>Normalised.</strong> "Per vCPU" and "per GB" divide the monthly price by what the plan states, in the plan's own currency. A value the source does not state stays Unknown and sorts last.</li>
--<li><strong>Not for sale.</strong> The order comes only from the sort you pick.${hasAffiliate ? ` ${esc(DISCLOSURE)}` : ' No provider pays to be listed or ranked.'}</li>
++<li><strong>Not for sale.</strong> The order comes only from the sort you pick. Every outbound link says what it earns us.${hasAffiliate ? ` ${esc(DISCLOSURE)}` : ' No provider pays to be listed or ranked.'}</li>
</ul>
</section>`;
}
@@@ -317,16 +321,18 @@ function plan(p, discussion = null, { baseUrl = '', watchUrl = '', extra = '', n
['Locations', p.locations && p.locations.length ? p.locations.join(', ') : null], ['Kind', kindName(p.kind)],
];
const value = [['Per vCPU', money(p.price_per_vcpu, p.currency)], ['Per GB of RAM', money(p.price_per_gb_ram, p.currency)], ['Per GB of disk', money(p.price_per_gb_disk, p.currency, 3)]];
-- const trend = p.price_trend;
++ // A zero or missing price has no trend to draw: the summary would print $0.00 as if it were a price (outcome 8).
++ const trend = Number(p.price_monthly) > 0 ? p.price_trend : null;
const chart = trendFigure(trend, { title: 'Monthly price over time', seriesName: 'Monthly price' }) || trendSummary(trend);
const history = p.price_history.length
? `<details class="hc-history-fold"><summary>Every change (${p.price_history.length})</summary><ol class="hc-history">${p.price_history.map((h) => `<li><time datetime="${esc(day(Number(h.observed_at)))}">${esc(day(Number(h.observed_at)))}</time> ${esc(money(h.price_monthly_before, h.currency) || h.price_monthly_before)} → <strong>${esc(money(h.price_monthly_after, h.currency) || h.price_monthly_after)}</strong></li>`).join('')}</ol></details>`
: '';
++ const priceHistory = chart || history ? `<h2>Price history</h2>\n${chart}\n${history}` : '';
return `<nav class="hc-crumbs" aria-label="Breadcrumb">${link('/catalog', 'Catalog')} / ${link(`/catalog/providers/${encodeURIComponent(p.provider_slug)}`, esc(p.provider_name))}</nav>
<header class="hc-head"><p class="hc-eyebrow">${esc(p.provider_name)} · ${esc(kindName(p.kind))}</p><h1>${esc(p.name)}</h1></header>
<div class="hc-plan">
<section class="hc-price-card" aria-label="Price">
--<p class="hc-big">${esc((() => { const price = Number(p.price_monthly); if (isStorageRate(p)) return Number.isFinite(price) ? money(price, p.currency, 4) : 'price not stated'; return (Number.isFinite(price) && price > 0) ? money(price, p.currency) : 'price not stated'; })())}<span>${isStorageRate(p) ? '/GB/month' : '/month'}</span></p>
++<p class="hc-big">${esc((() => { const price = Number(p.price_monthly); if (isStorageRate(p)) return Number.isFinite(price) && price > 0 ? money(price, p.currency, 4) : 'price not stated'; return (Number.isFinite(price) && price > 0) ? money(price, p.currency) : 'price not stated'; })())}<span>${isStorageRate(p) ? '/GB/month' : '/month'}</span></p>
${isStorageRate(p) ? '<p class="hc-dim">A storage rate: the price per GB a month, not a monthly server price.</p>' : ''}
${p.price_hourly != null ? `<p class="hc-dim">${esc(money(p.price_hourly, p.currency, 4))} an hour</p>` : ''}
${p.setup_fee != null && Number(p.setup_fee) > 0 ? `<p class="hc-dim">Setup fee ${esc(money(p.setup_fee, p.currency))}</p>` : ''}
@@@ -338,9 +344,7 @@ ${visit(p)}
<section aria-labelledby="hc-spec-h"><h2 id="hc-spec-h">What you get</h2><dl class="hc-spec">${spec.map(([k, v]) => `<div><dt>${esc(k)}</dt><dd>${or(v)}</dd></div>`).join('')}</dl></section>
<section aria-labelledby="hc-value-h"><h2 id="hc-value-h">Value</h2><dl class="hc-spec">${value.map(([k, v]) => `<div><dt>${esc(k)}</dt><dd>${or(v)}</dd></div>`).join('')}</dl></section>
</div>
--<h2>Price history</h2>
--${chart}
--${history}
++${priceHistory}
${baseUrl && watchUrl ? `<p class="hc-watch">${out(`${watchUrl}/watches/new?${new URLSearchParams({ template: 'json', url: `${baseUrl}/api/v1/catalog/plans/${p.id}`, value_path: 'plan.price_monthly', op: 'changed', name: `${p.provider_name} ${p.name} price` })}`, 'Tell me when this price changes', { cls: 'button hc-button-quiet' })} <span class="hc-dim">with OpenVibe.Watch, free</span></p>` : ''}
${extra}
${discussionSection(discussion)}
@@@ -361,7 +365,7 @@ function offerItem(o, now) {
: '<p class="hc-offer-price">Price in the post</p>';
const when = `<time datetime="${esc(o.published_at)}">${esc(day(o.published_at))}</time>`;
return `<li class="hc-offer">${price}
--<div class="hc-offer-body"><h3><a href="${esc(o.url)}" rel="nofollow ugc noopener">${esc(o.title)}</a></h3>
++<div class="hc-offer-body"><h3>${out(o.url, esc(o.title), { rel: 'nofollow ugc noopener' })}</h3>
<p class="hc-offer-meta">${esc(o.source)}${o.poster ? ` · posted by ${esc(o.poster)}` : ''} · ${when}${age > 30 ? ` <span class="hc-chip" title="Offers often end; the post says whether this one still runs">${age} days old</span>` : ''}</p></div></li>`;
}
function offers({ result, query = {}, now = Date.now() }) {
@@@ -412,10 +416,11 @@ const jsonLd = {
// One Product node for the plan's own page: the page's own heading is the plan's name,
// the page names the provider (so it is the brand), and the price it shows is the monthly
// price as a number. The plan is on sale only while it is active, so that is its availability.
++ // A zero, missing or per-GB rate is not a monthly Offer and says nothing (market outcome 8).
seo.jsonLd.product({
name: p.name, url, brand: { '@type': 'Brand', name: p.provider_name },
description: [p.vcpu != null ? `${Number(p.vcpu)} vCPU` : null, bytesMb(p.ram_mb) ? `${bytesMb(p.ram_mb)} RAM` : null, sizeGb(p.disk_gb) ? `${sizeGb(p.disk_gb)} disk` : null].filter(Boolean).join(', ') || undefined,
-- offers: (Number.isFinite(price) && price > 0) ? { '@type': 'Offer', price, priceCurrency: p.currency, url, availability: 'https://schema.org/InStock',
++ offers: (Number.isFinite(price) && price > 0 && !isStorageRate(p)) ? { '@type': 'Offer', price, priceCurrency: p.currency, url, availability: 'https://schema.org/InStock',
priceSpecification: { '@type': 'UnitPriceSpecification', price, priceCurrency: p.currency, unitText: 'MONTH' } } : undefined,
}),
];
@@@ -427,14 +432,15 @@ const jsonLd = {
const planCard = (label, p, note = '', now = Date.now()) => {
const price = Number(p.price_monthly);
const storage = isStorageRate(p);
-- const priceLabel = storage ? (Number.isFinite(price) ? money(price, p.currency, 4) : 'price not stated') : (Number.isFinite(price) && price > 0 ? money(price, p.currency) : 'price not stated');
++ const stated = Number.isFinite(price) && price > 0;
++ const priceLabel = storage ? (stated ? money(price, p.currency, 4) : 'price not stated') : (stated ? money(price, p.currency) : 'price not stated');
const unit = storage ? ' a GB a month' : ' a month';
-- return `<li>${link(`/catalog/plans/${encodeURIComponent(p.id)}`, `<span class="hh-label">${esc(label)}</span><b>${esc(p.provider_name)} ${esc(p.name)}</b><span class="hh-spec">${[p.vcpu != null ? `${num(p.vcpu)} vCPU` : null, bytesMb(p.ram_mb), sizeGb(p.disk_gb) ? `${sizeGb(p.disk_gb)} disk` : null].filter(Boolean).map(esc).join(' · ')}</span><span class="hh-price"><strong>${esc(priceLabel)}</strong>${priceLabel === 'price not stated' ? '' : unit}${note ? ` · ${esc(note)}` : ''}</span>${day(p.checked_at) ? `<span class="hh-spec">checked ${esc(day(p.checked_at))}${isStale(p.checked_at, now) ? ', stale' : ''}</span>` : ''}`)}</li>`;
++ return `<li>${link(`/catalog/plans/${encodeURIComponent(p.id)}`, `<span class="hh-label">${esc(label)}</span><b>${esc(p.provider_name)} ${esc(p.name)}</b><span class="hh-spec">${[p.vcpu != null ? `${num(p.vcpu)} vCPU` : null, bytesMb(p.ram_mb), sizeGb(p.disk_gb) ? `${sizeGb(p.disk_gb)} disk` : null].filter(Boolean).map(esc).join(' · ')}</span><span class="hh-price"><strong>${esc(priceLabel)}</strong>${priceLabel === 'price not stated' ? '' : unit}${note ? ` · ${esc(note)}` : ''}</span><span class="hh-spec">${esc(termText(p))} · ${esc(renewText(p))}</span>${day(p.checked_at) ? `<span class="hh-spec">checked ${esc(day(p.checked_at))}${isStale(p.checked_at, now) ? ', stale' : ''}</span>` : ''}`)}</li>`;
};
const perUnit = (p, by) => {
const price = Number(p.price_monthly);
const n = by === 'vcpu' ? Number(p.vcpu) : by === 'ram' ? Number(p.ram_mb) / 1024 : Number(p.disk_gb);
-- return Number.isFinite(price) && n > 0 ? money(price / n, p.currency) : null;
++ return Number.isFinite(price) && price > 0 && n > 0 ? money(price / n, p.currency) : null;
};
/** The home's extra blocks, each from real rows and left out when it has none. */
@@@ -465,7 +471,7 @@ function front({ plans, providers, promos: live, stats, offers: recent = [], dom
const servers = plans.filter((p) => p.vcpu != null && Number(p.price_monthly) > 0 && !isStale(p.checked_at, now));
const aside = servers.length ? `<div class="hc-aside" aria-label="Lowest prices right now">
<p class="hc-aside-h">Cheapest servers right now</p>
--<ol>${servers.slice(0, 5).map((p) => `<li>${link(`/catalog/plans/${encodeURIComponent(p.id)}`, `<span>${esc(p.provider_name)} <b>${esc(p.name)}</b></span><span class="hc-aside-spec">${[p.vcpu != null ? `${num(p.vcpu)} vCPU` : null, bytesMb(p.ram_mb)].filter(Boolean).map(esc).join(' · ')}</span><strong>${esc(money(p.price_monthly, p.currency))}</strong>`)}</li>`).join('')}</ol>
++<ol>${servers.slice(0, 5).map((p) => `<li>${link(`/catalog/plans/${encodeURIComponent(p.id)}`, `<span>${esc(p.provider_name)} <b>${esc(p.name)}</b></span><span class="hc-aside-spec">${[p.vcpu != null ? `${num(p.vcpu)} vCPU` : null, bytesMb(p.ram_mb)].filter(Boolean).map(esc).join(' · ')} · ${esc(termText(p))} · ${esc(renewText(p))}</span><strong>${esc(money(p.price_monthly, p.currency))}</strong>`)}</li>`).join('')}</ol>
<p class="hc-aside-foot">Checked ${esc(day(stats.lastChecked) || '')} · ${link('/catalog', 'compare all')}</p>
</div>` : '';
return showcase.hero({
@@@ -487,12 +493,12 @@ function front({ plans, providers, promos: live, stats, offers: recent = [], dom
{ icon: 'ov:check', title: 'Straight from the source', text: 'Every provider in one table, from their own APIs or from staff entries that name the page they came from. Nothing is estimated.' },
{ icon: 'ov:history', title: 'Dated prices', text: 'Every price shows when it was last checked, and price changes are kept as a history per plan.' },
{ icon: 'ov:search', title: 'Compare what matters', text: 'Sort by monthly price, or by price per vCPU, per GB of RAM or per GB of disk. Unknown stays unknown.' },
-- { icon: 'ov:account', title: 'Ranked by you', text: 'No paid placement. Affiliate links are marked, disclosed beside the link, and never change the order.' },
++ { icon: 'ov:account', title: 'Ranked by you', text: 'No paid placement. Every outbound link says what it earns us, and an affiliate link never changes the order.' },
],
}) + '</details></section>' + (recent.length ? `<section class="sc-sec"><h2>Latest hosting offers</h2><ol class="hc-offers">${recent.slice(0, 6).map((o) => offerItem(o, now)).join('')}</ol><p class="hc-more">${link('/catalog/offers', 'Every offer, by price or date →')}</p></section>` : '')
+ (live.length ? `<section class="sc-sec"><h2>Promotions running now</h2>${promoCards(live.slice(0, 3))}<p class="hc-more">${link('/catalog/promos', 'All promotions →')}</p></section>` : '')
+ extra.faq
-- + `<p class="hc-own">Want to host a site with OpenVibe? ${link('https://openvibe.space', 'Visit OpenVibe.Space')}.</p>`;
++ + `<p class="hc-own">Want to host a site with OpenVibe? ${out('https://openvibe.space', 'Visit OpenVibe.Space')}.</p>`;
}
module.exports = { front, catalog, providerList, provider, plan, promos, offers, planTable, jsonLd, DISCLOSURE, money, bytesMb, sizeGb, trendFigure, tldTrendFigure, trendSummary, day };
ddiff --git a/test/catalog-manage.test.js b/test/catalog-manage.test.js
iindex 027b3bb..77a86c3 100644
---- a/test/catalog-manage.test.js
++++ b/test/catalog-manage.test.js
@@@ -26,7 +26,7 @@ const { boot, check, done } = require('./helpers/boot');
let page = await t.api('GET', `/catalog/plans/${plan.id}`);
assert.strictEqual(page.status, 200, page.text.slice(0, 200));
assert.match(page.text, /Term not published\./);
-- assert.match(page.text, /No commission: we earn nothing from this link\./, 'a homepage link says it earns nothing');
++ assert.match(page.text, /No commission\./, 'a homepage link says it earns nothing');
// A 1-year term with no renewal price stated, then one with it.
let r = await t.api('PATCH', `/api/v1/catalog/plans/${plan.id}`, { as: staff, json: { price_term: '1-year' } }); assert.strictEqual(r.status, 200, r.text);
page = await t.api('GET', `/catalog/plans/${plan.id}`);
@@@ -38,7 +38,7 @@ const { boot, check, done } = require('./helpers/boot');
r = await t.api('PATCH', `/api/v1/catalog/plans/${plan.id}`, { as: staff, json: { price_term: 'weekly' } }); assert.strictEqual(r.status, 422, r.text);
r = await t.api('PATCH', `/api/v1/catalog/plans/${plan.id}`, { as: staff, json: { price_term: 'monthly', renewal_price: null } }); assert.strictEqual(r.status, 200, r.text);
page = await t.api('GET', `/catalog/plans/${plan.id}`);
-- assert.match(page.text, /Month to month\. At the provider&#39;s list price\.|Month to month\. At the provider's list price\./);
++ assert.match(page.text, /Monthly\. Renewal price not published\./);
});
await check('staff manage form is server rendered and CSRF protected', async () => {
const r = await t.api('GET', '/catalog/manage', { session: staff }); assert.strictEqual(r.status, 200); assert.match(r.text, /<form method="post"/); assert.match(r.text, /name="csrf"/);
ddiff --git a/test/catalog-pricing.test.js b/test/catalog-pricing.test.js
iindex a290ab8..8a7c095 100644
---- a/test/catalog-pricing.test.js
++++ b/test/catalog-pricing.test.js
@@@ -20,18 +20,27 @@ const YEAR = 365 * 86400000;
const catalog = createCatalog({ store });
try {
const provider = await catalog.save('providers', { slug: 'azure', name: 'Microsoft Azure', homepage_url: 'https://azure.microsoft.com/' }, null, 'tester');
-- const plan = async (over) => await catalog.save('plans', {
-- provider_id: provider.id, external_id: over.external_id, name: over.name, kind: over.kind || 'cloud',
++ const planFor = async (p, over) => await catalog.save('plans', {
++ provider_id: p.id, external_id: over.external_id, name: over.name, kind: over.kind || 'cloud',
currency: 'USD', price_monthly: over.price_monthly, source_url: 'https://azure.microsoft.com/en-us/pricing/',
vcpu: over.vcpu == null ? null : over.vcpu, ram_mb: over.ram_mb == null ? null : over.ram_mb,
disk_gb: over.disk_gb == null ? null : over.disk_gb, checked_at: over.checked_at == null ? NOW : over.checked_at,
++ price_term: over.price_term, renewal_price: over.renewal_price,
}, null, 'tester');
++ const plan = (over) => planFor(provider, over);
const fresh = await plan({ external_id: 'fresh', name: 'Standard_D2s_v5', price_monthly: 5, vcpu: 2, ram_mb: 8192, disk_gb: 64 });
const stale = await plan({ external_id: 'stale', name: 'Standard_D1s_v5', price_monthly: 1, vcpu: 1, ram_mb: 2048, disk_gb: 32, checked_at: NOW - (YEAR + 86400000) });
const rate = await plan({ external_id: 'rate', name: 'Archive LRS (per GB/month)', kind: 'storage', price_monthly: 0.001 });
const unpriced = await plan({ external_id: 'unpriced', name: 'Free tier', price_monthly: 0 });
++ // A second provider with a term stated, a renewal stated and a zero price that still carries specs: the
++ // per-resource columns must not divide it into $0.00 (market outcome 8).
++ const affiliate = await catalog.save('providers', { slug: 'example', name: 'Example', homepage_url: 'https://example.test', affiliate_url: 'https://example.test/ref', affiliate_note: 'Referral to Example.' }, null, 'tester');
++ const monthly = await planFor(affiliate, { external_id: 'monthly', name: 'Monthly plan', price_monthly: 6, vcpu: 1, ram_mb: 1024, disk_gb: 20, price_term: 'monthly' });
++ const yearly = await planFor(affiliate, { external_id: 'yearly', name: 'Yearly plan', price_monthly: 12, vcpu: 1, ram_mb: 1024, disk_gb: 20, price_term: '1-year', renewal_price: 9.5 });
++ const free = await planFor(affiliate, { external_id: 'free', name: 'Free with specs', price_monthly: 0, vcpu: 1, ram_mb: 1024, disk_gb: 20, price_term: 'monthly' });
++
await check('a price list puts the fresh monthly prices first and the rates and the stale price last', async () => {
const rows = (await catalog.plans({ provider: 'azure' })).plans;
assert.deepStrictEqual(rows.map((x) => x.external_id), ['fresh', 'stale', 'unpriced', 'rate']);
@@@ -55,6 +64,51 @@ const YEAR = 365 * 86400000;
assert.ok(html.includes('/GB/month'), 'the rate is shown per GB');
assert.ok(html.includes('2025-10-31, <span class="hc-stale"'), 'a stale price is marked as stale');
assert.ok(html.includes('not counted in the price above'), 'the page says what is not counted');
++ assert.ok(html.includes('<span class="hc-term">term not published</span>'), 'a row with no term says so');
++ assert.ok(html.includes('<span class="hc-term">Renewal price not published</span>'), 'a row with no renewal says so');
++ });
++
++ await check('a term and a stated renewal render on every plan row, and a zero price is never $0.00', async () => {
++ const html = render.provider(await catalog.providers('example'), null, '', NOW);
++ assert.ok(html.includes('<span class="hc-term">Monthly</span>'), 'a monthly row states its term');
++ assert.ok(html.includes('<span class="hc-term">1-year term</span>'), 'a 1-year row states its term');
++ assert.ok(html.includes('<span class="hc-term">Renews at $9.50 a month</span>'), 'a stated renewal is shown');
++ assert.ok(html.includes('<span class="hc-price">price not stated</span>'), 'a zero price reads "price not stated"');
++ assert.ok(!/\$0\.00(?!\d)/.test(html), 'no row renders $0.00 for an unknown price');
++ assert.ok(html.includes('data-label="Per vCPU" class="hc-n hc-dim"><span class="hc-unknown"'), 'per-resource ratios of a zero price stay Unknown');
++
++ const monthlyPage = render.plan(await catalog.plan(monthly.id), null, { now: NOW });
++ assert.ok(monthlyPage.includes('Monthly. Renewal price not published.'), 'the plan page states a monthly term and no renewal');
++ const yearlyPage = render.plan(await catalog.plan(yearly.id), null, { now: NOW });
++ assert.ok(yearlyPage.includes('1-year term. Renews at $9.50 a month.'), 'the plan page states the term and renewal');
++ const freePage = render.plan(await catalog.plan(free.id), null, { now: NOW });
++ assert.ok(freePage.includes('<p class="hc-big">price not stated<span>/month</span></p>'), 'the plan page does not print $0.00');
++ assert.ok(!/\$0\.00(?!\d)/.test(freePage), 'a zero price never renders $0.00 on a plan page');
++ const freeProduct = render.jsonLd.plan('https://openvibe.host', await catalog.plan(free.id)).find((x) => x['@type'] === 'Product');
++ assert.strictEqual(freeProduct.offers, undefined, 'a zero price is not a Product Offer');
++ });
++
++ await check('every outbound link says what it earns us (market outcome 7)', async () => {
++ const outbound = (html) => [...html.matchAll(/<a\b[^>]*href="https?:\/\/[^"]*"[^>]*>[\s\S]*?<\/a>(?:\s*<small class="hc-note">[^<]*<\/small>)?/g)].map((m) => m[0]);
++ const providerPage = render.provider(await catalog.providers('example'), null, '', NOW);
++ const links = outbound(providerPage);
++ assert.ok(links.length >= 4, `the provider page has its visit, homepage, review and source links: ${links.length}`);
++ for (const a of links) assert.match(a, /(?:No commission|We earn a commission on this link)/, a.slice(0, 160));
++ assert.ok(providerPage.includes('We earn a commission on this link.'), 'an affiliate link says we earn a commission');
++ assert.ok(providerPage.includes('No commission.'), 'a link with no deal says we earn nothing');
++ const planPage = render.plan(await catalog.plan(monthly.id), null, { now: NOW, baseUrl: 'https://openvibe.host', watchUrl: 'https://openvibe.watch' });
++ assert.ok(outbound(planPage).length >= 3, 'the plan page marks its source, visit and watch links');
++ for (const a of outbound(planPage)) assert.match(a, /(?:No commission|We earn a commission on this link)/, a.slice(0, 160));
++ const comparePage = render.catalog({ result: await catalog.plans({}), query: {}, providers: await catalog.providers(), currencies: ['USD'] });
++ assert.ok(outbound(comparePage).length >= 2, 'the comparison marks its source links');
++ for (const a of outbound(comparePage)) assert.match(a, /(?:No commission|We earn a commission on this link)/, a.slice(0, 160));
++ });
++
++ await check('no uptime, benchmark score or test method field is shown (outcomes 4, 5 and 6 need data we do not collect)', async () => {
++ const page = render.plan(await catalog.plan(fresh.id), null, { now: NOW });
++ assert.ok(!/uptime/i.test(page), 'no uptime field');
++ assert.ok(!/normalised|normalized|100 = fastest|benchmark score/i.test(page), 'no benchmark score');
++ assert.ok(!/test method|sample size/i.test(page), 'no test method or sample size');
});
await check('a plan page shows a rate per GB as a rate and marks a stale price', async () => {
ddiff --git a/test/catalog-public.test.js b/test/catalog-public.test.js
iindex 44d3f03..bf91c75 100644
---- a/test/catalog-public.test.js
++++ b/test/catalog-public.test.js
@@@ -9,7 +9,7 @@ const { boot, check, done } = require('./helpers/boot');
const p = await post('providers', { slug: 'alpha', name: 'Alpha', homepage_url: 'https://alpha.test', affiliate_url: 'https://alpha.test/ref', affiliate_note: 'Referral to Alpha.' });
const q = await post('providers', { slug: 'beta', name: 'Beta', homepage_url: 'https://beta.test' });
const plan = async (provider, id, price, extra = {}) => await post('plans', { provider_id: provider.id, external_id: id, name: id, kind: 'vps', currency: 'USD', price_monthly: price, source_url: 'https://alpha.test/pricing', ...extra });
-- const cheap = await plan(p, 'cheap', 4, { vcpu: 1, ram_mb: 1024, disk_gb: 20, locations: ['us'] });
++ const cheap = await plan(p, 'cheap', 4, { vcpu: 1, ram_mb: 1024, disk_gb: 20, locations: ['us'], price_term: 'monthly' });
await plan(q, 'other', 8, { vcpu: 4, ram_mb: 4096, disk_gb: 80, locations: ['eu'] });
const hidden = await plan(p, 'hidden', 1, { status: 'hidden' });
const gone = await plan(p, 'gone', 1, { status: 'gone' });
@@@ -34,14 +34,25 @@ const { boot, check, done } = require('./helpers/boot');
assert.strictEqual((await t.api('GET', '/api/v1/catalog/plans')).json().plans.find((x) => x.external_id === 'unknown').price_per_vcpu, null);
});
await check('affiliate disclosure and rel render; promos end by time', async () => {
++ const free = await plan(q, 'free', 0, { vcpu: 1, ram_mb: 1024, disk_gb: 20, price_term: 'monthly' });
const page = await t.api('GET', `/catalog/providers/${p.slug}`); assert.strictEqual(page.status, 200); assert.match(page.text, /rel="sponsored nofollow noopener"/); assert.match(page.text, /OpenVibe may earn a commission/);
assert.ok(page.text.includes(`href="https://openvibe.reviews/review?kind=hosting&amp;name=${encodeURIComponent(p.name)}"`), 'a link to review the provider');
assert.match(page.text, /href="https:\/\/openvibe\.reviews\/everywhere\?q=[^"]+&amp;kind=hosting"/);
++ assert.match(page.text, /<span class="hc-term">Monthly<\/span>/, 'a monthly plan states its term');
++ assert.match(page.text, /<span class="hc-term">Renewal price not published<\/span>/, 'an unpublished renewal says so');
++ assert.match(page.text, /We earn a commission on this link\./, 'the affiliate visit link says we earn a commission');
++ assert.match(page.text, /No commission\./, 'the source link says we earn nothing');
const plans = await t.api('GET', '/catalog'); assert.match(plans.text, /<form class="hc-filters" method="get" action="\/catalog"/); assert.match(plans.text, /<table class="hc-table">/);
assert.match(plans.text, /How the comparison works/); assert.doesNotMatch(plans.text, /<script[^>]*src="[^"]*catalog/, 'the catalog needs no script of its own');
++ assert.ok(!/\$0\.00(?!\d)/.test(plans.text), 'the comparison never prints $0.00 for an unknown price');
++ assert.match(plans.text, /<span class="hc-term">Monthly<\/span>/);
++ assert.match(plans.text, /<span class="hc-term">term not published<\/span>/, 'a plan with no term says so');
const front = await t.api('GET', '/'); assert.strictEqual(front.status, 200);
assert.match(front.text, /Cheapest servers right now/); assert.match(front.text, /\$4\.00/, 'the cheapest plan is on the front page');
assert.match(front.text, /css\/catalog\.css\?v=/);
++ assert.ok(!/\$0\.00(?!\d)/.test(front.text), 'the home never prints $0.00 for an unknown price');
++ assert.match(front.text, /<span class="hc-term">Monthly<\/span>/);
++ assert.match(front.text, /<span class="hc-term">Renewal price not published<\/span>/);
for (const h of ['The lowest monthly prices', 'The lowest price for each size', 'The most for your money', 'Providers compared', 'Questions people ask']) assert.ok(front.text.includes(h), `the front page has "${h}"`);
assert.match(front.text, /<title>Hosting plans and domain prices, compared · OpenVibe\.Host<\/title>/);
assert.match(front.text, /<ul class="hh-stats"[^>]*><li><a href="\/catalog"><b>\d+<\/b><span>hosting plans compared<\/span>/, 'the counts are real and link where they come from');
@@@ -68,6 +79,20 @@ const { boot, check, done } = require('./helpers/boot');
assert.match(planPage.text, /class="hc-visit"/, 'a plan page offers a way to the provider');
assert.match(planPage.text, /href="https:\/\/openvibe\.watch\/watches\/new\?template=json&amp;url=[^"]*%2Fapi%2Fv1%2Fcatalog%2Fplans%2F[^"]*value_path=plan\.price_monthly&amp;op=changed/, 'a plan page offers a prefilled price watch');
assert.ok(product && product.offers.price === 4 && product.offers.priceCurrency === 'USD' && product.offers.priceSpecification.unitText === 'MONTH');
++ const freePage = await t.api('GET', `/catalog/plans/${free.id}`); assert.strictEqual(freePage.status, 200);
++ assert.match(freePage.text, /price not stated/, 'a zero price is not shown as $0.00');
++ assert.ok(!/\$0\.00(?!\d)/.test(freePage.text), 'a zero price never renders $0.00 on a plan page');
++ const freeProduct = ld(freePage.text).find((x) => x['@type'] === 'Product');
++ assert.strictEqual(freeProduct.offers, undefined, 'a zero price carries no Product Offer');
++ // Market outcome 7 on every page the catalog renders: each outbound link in the page content says what it
++ // earns us. The shared site chrome (the network's own footer) is not catalog content and is not counted.
++ const main = (html) => (html.match(/<main[^>]*>([\s\S]*)<\/main>/) || [, ''])[1];
++ const outbound = (html) => [...main(html).matchAll(/<a\b[^>]*href="https?:\/\/[^"]*"[^>]*>[\s\S]*?<\/a>(?:\s*<small class="hc-note">[^<]*<\/small>)?/g)].map((m) => m[0]);
++ for (const [name, html] of [['the provider page', page.text], ['the comparison', plans.text], ['the home', front.text], ['a plan page', planPage.text], ['a zero-price plan page', freePage.text]]) {
++ const links = outbound(html);
++ assert.ok(links.length > 0, `${name} has an outbound link`);
++ for (const a of links) assert.match(a, /(?:No commission|We earn a commission on this link)/, `${name}: ${a.slice(0, 160)}`);
++ }
assert.ok(!types((await t.api('GET', '/catalog?min_vcpu=2')).text).includes('ItemList'), 'a filtered view carries no JSON-LD');
const promos = await t.api('GET', '/api/v1/catalog/promos'); assert.strictEqual(promos.json().promos.length, 1);
});