openvibers/openvibe.host Commits

Sort zero/missing plan prices last and show price not stated (#237)

OP OpenVibers committed 12 days ago · 401bd46574 · 3 files, +23 −14

* Sort zero/missing plan prices last and show price not stated

* Trigger CI

* Merge main

Changes

ddiff --git a/server/catalog/domain-store.js b/server/catalog/domain-store.js
iindex 4b7cc10..1ca7a78 100644
---- a/server/catalog/domain-store.js
++++ b/server/catalog/domain-store.js
@@@ -16,9 +16,9 @@ const FIVE_YEAR = '(COALESCE(p.register, p.renew) + 4 * COALESCE(p.renew, p.regi
const SORTS = {
popular: `MIN(${POPULAR_RANK}) ASC, p.tld ASC`,
tld: 'p.tld ASC',
-- register: 'MIN(p.register) ASC NULLS LAST, p.tld ASC',
-- renew: 'MIN(p.renew) ASC NULLS LAST, p.tld ASC',
-- five_year: `MIN(${FIVE_YEAR}) ASC NULLS LAST, p.tld ASC`,
++ register: 'CASE WHEN MIN(p.register) IS NULL OR MIN(p.register) = 0 THEN 1 ELSE 0 END ASC, MIN(p.register) ASC NULLS LAST, p.tld ASC',
++ renew: 'CASE WHEN MIN(p.renew) IS NULL OR MIN(p.renew) = 0 THEN 1 ELSE 0 END ASC, MIN(p.renew) ASC NULLS LAST, p.tld ASC',
++ five_year: `CASE WHEN MIN(${FIVE_YEAR}) IS NULL OR MIN(${FIVE_YEAR}) = 0 THEN 1 ELSE 0 END ASC, MIN(${FIVE_YEAR}) ASC NULLS LAST, p.tld ASC`,
};
const KINDS = {
country: "p.tld ~ '^[a-z]{2}$'",
@@@ -111,7 +111,7 @@ function createDomainStore({ store, announce = () => {} }) {
const t = domains.normalizeTld(String(input || ''));
if (!t) throw new ApiError(404, 'host.catalog.not_found', 'Not a domain ending');
const rows = await db.prepare(`SELECT p.*, ${FIVE_YEAR} AS five_year, v.slug AS provider_slug, v.name AS provider_name, v.homepage_url, v.affiliate_url, v.affiliate_note
-- FROM catalog_tld_prices p JOIN catalog_providers v ON v.id = p.provider_id WHERE ${ACTIVE} AND p.tld = ? ORDER BY p.register ASC NULLS LAST, v.name, p.id`).all(t);
++ FROM catalog_tld_prices p JOIN catalog_providers v ON v.id = p.provider_id WHERE ${ACTIVE} AND p.tld = ? ORDER BY CASE WHEN p.register IS NULL OR p.register = 0 THEN 1 ELSE 0 END ASC, p.register ASC NULLS LAST, v.name, p.id`).all(t);
const history = rows.length ? await db.prepare(`SELECT h.*, v.name AS provider_name FROM catalog_tld_price_changes h JOIN catalog_tld_prices p ON p.id = h.price_id JOIN catalog_providers v ON v.id = p.provider_id
WHERE p.tld = ? AND ${ACTIVE} ORDER BY h.observed_at DESC`).all(t) : [];
const data = { rows, history };
ddiff --git a/server/catalog/store.js b/server/catalog/store.js
iindex 2f625cc..6e4b3b8 100644
---- a/server/catalog/store.js
++++ b/server/catalog/store.js
@@@ -167,7 +167,7 @@ function createCatalog({ store, community = null, log = console, indexnow = null
return 'applied';
}
// Offers posted in the last MAX_AGE_DAYS: newest first, or by the stated monthly price within one currency.
-- const OFFER_SORTS = { new: 'published_at DESC, id DESC', price: 'price_monthly ASC, published_at DESC, id DESC' };
++ const OFFER_SORTS = { new: 'published_at DESC, id DESC', price: 'CASE WHEN price_monthly IS NULL OR price_monthly = 0 THEN 1 ELSE 0 END ASC, price_monthly ASC, published_at DESC, id DESC' };
const offerOut = (o) => ({ ...o, source: offerFeed.SOURCES[o.source_key] ? offerFeed.SOURCES[o.source_key].label : o.source_key, published_at: new Date(Number(o.published_at)).toISOString(), checked_at: new Date(Number(o.checked_at)).toISOString(),
price_amount: o.price_amount == null ? null : Number(o.price_amount), price_monthly: o.price_monthly == null ? null : Number(o.price_monthly) });
async function offers(query = {}) {
@@@ -202,7 +202,11 @@ function createCatalog({ store, community = null, log = console, indexnow = null
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}`);
const sort = query.sort || 'price';
-- const sorts = { price: 'p.price_monthly', price_per_vcpu: 'p.price_monthly / NULLIF(p.vcpu, 0)', price_per_gb_ram: 'p.price_monthly * 1024 / NULLIF(p.ram_mb, 0)', price_per_gb_disk: 'p.price_monthly / NULLIF(p.disk_gb, 0)', checked_at: 'p.checked_at' };
++ const sorts = { price: 'CASE WHEN p.price_monthly IS NULL OR p.price_monthly = 0 THEN 1 ELSE 0 END, p.price_monthly',
++ price_per_vcpu: 'CASE WHEN p.price_monthly IS NULL OR p.price_monthly = 0 THEN NULL ELSE p.price_monthly / NULLIF(p.vcpu, 0) END',
++ price_per_gb_ram: 'CASE WHEN p.price_monthly IS NULL OR p.price_monthly = 0 THEN NULL ELSE p.price_monthly * 1024 / NULLIF(p.ram_mb, 0) END',
++ price_per_gb_disk: 'CASE WHEN p.price_monthly IS NULL OR p.price_monthly = 0 THEN NULL ELSE p.price_monthly / NULLIF(p.disk_gb, 0) END',
++ checked_at: 'p.checked_at' };
if (!sorts[sort]) invalid('Invalid sort');
const args = [query.currency || 'USD'];
if (!currencies.has(args[0])) invalid('Invalid currency');
@@@ -261,7 +265,7 @@ function createCatalog({ store, community = null, log = console, indexnow = null
if (slug) {
const p = await db.prepare("SELECT * FROM catalog_providers WHERE slug = ? AND status = 'active'").get(slug);
if (!p) missing();
-- return { ...providerOut(p), plans: (await db.prepare("SELECT * FROM catalog_plans WHERE provider_id = ? AND status = 'active' ORDER BY price_monthly, id").all(p.id)).map((row) => planOut({ ...row, provider_name: p.name, provider_slug: p.slug, affiliate_url: p.affiliate_url, affiliate_note: p.affiliate_note })), promos: await promos(p.id), domains: await domains.countFor(p.id) };
++ return { ...providerOut(p), plans: (await db.prepare("SELECT * FROM catalog_plans WHERE provider_id = ? AND status = 'active' ORDER BY CASE WHEN price_monthly IS NULL OR price_monthly = 0 THEN 1 ELSE 0 END, price_monthly, id").all(p.id)).map((row) => planOut({ ...row, provider_name: p.name, provider_slug: p.slug, affiliate_url: p.affiliate_url, affiliate_note: p.affiliate_note })), promos: await promos(p.id), domains: await domains.countFor(p.id) };
}
return (await db.prepare("SELECT * FROM catalog_providers WHERE status = 'active' ORDER BY name, id").all()).map(providerOut);
}
@@@ -282,11 +286,11 @@ function createCatalog({ store, community = null, log = console, indexnow = null
}
/** Each provider with active plans: how many, the lowest monthly price and the newest check (the front page's cards). */
async function providerSummary(currency = 'USD') {
-- const rows = await db.prepare("SELECT v.slug, v.name, COUNT(*) AS plans, MIN(p.price_monthly) AS from_price, MAX(p.checked_at) AS last FROM catalog_plans p JOIN catalog_providers v ON v.id = p.provider_id WHERE p.status = 'active' AND v.status = 'active' AND p.currency = ? GROUP BY v.slug, v.name ORDER BY MIN(p.price_monthly) ASC, v.name").all(currency);
++ const rows = await db.prepare("SELECT v.slug, v.name, COUNT(*) AS plans, MIN(CASE WHEN p.price_monthly > 0 THEN p.price_monthly END) AS from_price, MAX(p.checked_at) AS last FROM catalog_plans p JOIN catalog_providers v ON v.id = p.provider_id WHERE p.status = 'active' AND v.status = 'active' AND p.currency = ? GROUP BY v.slug, v.name ORDER BY MIN(CASE WHEN p.price_monthly > 0 THEN p.price_monthly END) ASC, v.name").all(currency);
return rows.map((r) => ({ slug: r.slug, name: r.name, plans: Number(r.plans) || 0, from_price: r.from_price == null ? null : Number(r.from_price), currency, last_checked: r.last == null ? null : Number(r.last) }));
}
/** Every public plan for a form's choice list: provider by name, then cheapest first. */
-- async function planChoices() { return await db.prepare("SELECT p.id, p.name, v.name AS provider_name, v.slug AS provider_slug FROM catalog_plans p JOIN catalog_providers v ON v.id = p.provider_id WHERE p.status = 'active' AND v.status = 'active' ORDER BY v.name, p.price_monthly, p.id LIMIT 1000").all(); }
++ async function planChoices() { return await db.prepare("SELECT p.id, p.name, v.name AS provider_name, v.slug AS provider_slug FROM catalog_plans p JOIN catalog_providers v ON v.id = p.provider_id WHERE p.status = 'active' AND v.status = 'active' ORDER BY v.name, CASE WHEN p.price_monthly IS NULL OR p.price_monthly = 0 THEN 1 ELSE 0 END, p.price_monthly, p.id LIMIT 1000").all(); }
// Each public plan with the day its price was last checked, and its provider's slug, so the sitemap can date pages.
async function sitemapPlans() { return await db.prepare("SELECT p.id, p.checked_at, v.slug AS provider_slug FROM catalog_plans p JOIN catalog_providers v ON v.id = p.provider_id WHERE p.status = 'active' AND v.status = 'active' ORDER BY p.id").all(); }
return { onChange: (fn) => listeners.push(fn), save, get, applyItem, domains, plans, plan, planPriceTrend, recentChanges, providers, promos, offers, currencies: currenciesInUse, stats, providerSummary, sitemapPlans, planChoices, discussionIdle: idle, all: async (type) => { const rows = await db.prepare(`SELECT * FROM catalog_${type} ORDER BY created_at DESC, id DESC LIMIT 200`).all(); return type === 'promos' ? rows.map(promoOut) : rows; } };
ddiff --git a/server/render/catalog.js b/server/render/catalog.js
iindex 06cbd40..e15f16b 100644
---- a/server/render/catalog.js
++++ b/server/render/catalog.js
@@@ -61,8 +61,9 @@ function visit(p, label = 'Visit provider') {
}
function priceCell(p) {
-- const m = money(p.price_monthly, p.currency);
-- return `<span class="hc-price">${m ? esc(m) : UNKNOWN}</span><span class="hc-per">/mo</span>`;
++ const price = Number(p.price_monthly);
++ const m = Number.isFinite(price) && price > 0 ? money(price, p.currency) : null;
++ return `<span class="hc-price">${m ? esc(m) : 'price not stated'}</span><span class="hc-per">/mo</span>`;
}
const isoAt = (v) => new Date(v).toISOString();
@@@ -297,7 +298,7 @@ function plan(p, discussion = null, { baseUrl = '', watchUrl = '', extra = '' }
<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(money(p.price_monthly, p.currency) || '')}<span>/month</span></p>
++<p class="hc-big">${esc((() => { const price = Number(p.price_monthly); return (Number.isFinite(price) && price > 0) ? money(price, p.currency) : 'price not stated'; })())}<span>/month</span></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>` : ''}
<p class="hc-checked-line">Checked <strong>${esc(day(p.checked_at) || 'unknown')}</strong> · ${out(p.source_url, 'source')}</p>
@@@ -382,7 +383,7 @@ const jsonLd = {
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) ? { '@type': 'Offer', price, priceCurrency: p.currency, url, availability: 'https://schema.org/InStock',
++ offers: (Number.isFinite(price) && price > 0) ? { '@type': 'Offer', price, priceCurrency: p.currency, url, availability: 'https://schema.org/InStock',
priceSpecification: { '@type': 'UnitPriceSpecification', price, priceCurrency: p.currency, unitText: 'MONTH' } } : undefined,
}),
];
@@@ -391,7 +392,11 @@ const jsonLd = {
/** The front page of the hosting comparison. */
/** A plan as a small card: what it is, its size and its price, opening the plan's page. */
--const planCard = (label, p, note = '') => `<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(money(p.price_monthly, p.currency))}</strong> a month${note ? ` · ${esc(note)}` : ''}</span>`)}</li>`;
++const planCard = (label, p, note = '') => {
++ const price = Number(p.price_monthly);
++ const priceLabel = Number.isFinite(price) && price > 0 ? money(price, p.currency) : 'price not stated';
++ 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' ? '' : ' a month'}${note ? ` · ${esc(note)}` : ''}</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);