openvibers/openvibe.host Commits
Sort zero/missing plan prices last and show price not stated (#237)
Changes
@@@ -16,9 +16,9 @@ const FIVE_YEAR = '(COALESCE(p.register, p.renew) + 4 * COALESCE(p.renew, p.regiconst 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_idWHERE p.tld = ? AND ${ACTIVE} ORDER BY h.observed_at DESC`).all(t) : [];const data = { rows, history };@@@ -167,7 +167,7 @@ function createCatalog({ store, community = null, log = console, indexnow = nullreturn '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 = nullconst 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 = nullif (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; } };@@@ -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);
@@ -16,9 +16,9 @@ const FIVE_YEAR = '(COALESCE(p.register, p.renew) + 4 * COALESCE(p.renew, p.regi
@@ -16,9 +16,9 @@ const FIVE_YEAR = '(COALESCE(p.register, p.renew) + 4 * COALESCE(p.renew, p.regi
const SORTS = {
const SORTS = {
popular: `MIN(${POPULAR_RANK}) ASC, p.tld ASC`,
popular: `MIN(${POPULAR_RANK}) ASC, p.tld ASC`,
tld: '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 = {
const KINDS = {
country: "p.tld ~ '^[a-z]{2}$'",
country: "p.tld ~ '^[a-z]{2}$'",
@@ -111,7 +111,7 @@ function createDomainStore({ store, announce = () => {} }) {
@@ -111,7 +111,7 @@ function createDomainStore({ store, announce = () => {} }) {
const t = domains.normalizeTld(String(input || ''));
const t = domains.normalizeTld(String(input || ''));
if (!t) throw new ApiError(404, 'host.catalog.not_found', 'Not a domain ending');
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
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
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) : [];
WHERE p.tld = ? AND ${ACTIVE} ORDER BY h.observed_at DESC`).all(t) : [];
const data = { rows, history };
const data = { rows, history };
@@ -167,7 +167,7 @@ function createCatalog({ store, community = null, log = console, indexnow = null
@@ -167,7 +167,7 @@ function createCatalog({ store, community = null, log = console, indexnow = null
return 'applied';
return 'applied';
}
}
// Offers posted in the last MAX_AGE_DAYS: newest first, or by the stated monthly price within one currency.
// 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(),
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) });
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 = {}) {
async function offers(query = {}) {
@@ -202,7 +202,11 @@ function createCatalog({ store, community = null, log = console, indexnow = null
@@ -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']);
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}`);
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 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');
if (!sorts[sort]) invalid('Invalid sort');
const args = [query.currency || 'USD'];
const args = [query.currency || 'USD'];
if (!currencies.has(args[0])) invalid('Invalid currency');
if (!currencies.has(args[0])) invalid('Invalid currency');
@@ -261,7 +265,7 @@ function createCatalog({ store, community = null, log = console, indexnow = null
@@ -261,7 +265,7 @@ function createCatalog({ store, community = null, log = console, indexnow = null
if (slug) {
if (slug) {
const p = await db.prepare("SELECT * FROM catalog_providers WHERE slug = ? AND status = 'active'").get(slug);
const p = await db.prepare("SELECT * FROM catalog_providers WHERE slug = ? AND status = 'active'").get(slug);
if (!p) missing();
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);
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
@@ -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). */
/** 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') {
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) }));
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. */
/** 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.
// 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(); }
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; } };
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; } };
@@ -61,8 +61,9 @@ function visit(p, label = 'Visit provider') {
@@ -61,8 +61,9 @@ function visit(p, label = 'Visit provider') {
}
}
function priceCell(p) {
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();
const isoAt = (v) => new Date(v).toISOString();
@@ -297,7 +298,7 @@ function plan(p, discussion = null, { baseUrl = '', watchUrl = '', extra = '' }
@@ -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>
<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">
<div class="hc-plan">
<section class="hc-price-card" aria-label="Price">
<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.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.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>
<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 = {
@@ -382,7 +383,7 @@ const jsonLd = {
seo.jsonLd.product({
seo.jsonLd.product({
name: p.name, url, brand: { '@type': 'Brand', name: p.provider_name },
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,
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,
priceSpecification: { '@type': 'UnitPriceSpecification', price, priceCurrency: p.currency, unitText: 'MONTH' } } : undefined,
}),
}),
];
];
@@ -391,7 +392,11 @@ const jsonLd = {
@@ -391,7 +392,11 @@ const jsonLd = {
/** The front page of the hosting comparison. */
/** 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. */
/** 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 perUnit = (p, by) => {
const price = Number(p.price_monthly);
const price = Number(p.price_monthly);
const n = by === 'vcpu' ? Number(p.vcpu) : by === 'ram' ? Number(p.ram_mb) / 1024 : Number(p.disk_gb);
const n = by === 'vcpu' ? Number(p.vcpu) : by === 'ram' ? Number(p.ram_mb) / 1024 : Number(p.disk_gb);