openvibers/openvibe.host Commits
Host: empty catalog and offers lists draw the kit's empty state (#361)
Changes
@@@ -94,6 +94,7 @@ function createCatalogViews({ catalog, benchmarks, community, config, log = consreturn {title: 'Compare hosting plans',body: render.catalog({ result, query, providers, currencies, dedicated }),++ styles: ['kit.css'],indexable: !Object.keys(query).length,description: 'Compare cloud, VPS and dedicated hosting plans: price per month, per vCPU and per GB, each with the day it was checked and its source.',jsonLd: render.jsonLd.catalog(baseUrl, result.plans),@@@ -144,7 +145,7 @@ function createCatalogViews({ catalog, benchmarks, community, config, log = consif (q.sort === 'price' && !q.currency) q.currency = 'USD';const result = await catalog.offers(q);return {-- title: 'Hosting offers', body: render.offers({ result, query: q }), indexable: !Object.keys(query).length,++ title: 'Hosting offers', body: render.offers({ result, query: q }), indexable: !Object.keys(query).length, styles: ['kit.css'],description: "Hosting offers from LowEndSpirit's Offers board, newest first or cheapest a month, each with the price its headline states and the day it was posted.",};}@@@ -228,8 +228,8 @@ function catalog({ result, query, providers, currencies, dedicated = { plans: []const plans = result.plans;const filtered = Object.keys(query || {}).some((k) => k !== 'sort' && k !== 'cursor' && query[k]);const empty = plans.length ? '' : filtered-- ? `<div class="hc-empty"><p><strong>No plan matches these filters.</strong></p><p>${link('/catalog', 'Clear the filters')} or widen one of them.</p></div>`-- : `<div class="hc-empty"><p><strong>The catalog is filling up.</strong></p><p>Plans arrive from providers' official APIs as each source is checked and enabled, and from staff entries with their source pages.</p></div>`;++ ? kit.empty({ icon: 'search', title: 'No plan matches these filters', text: 'Widen one of the filters to see more plans.', action: { href: '/catalog', label: 'Clear the filters', icon: 'grid' } })++ : kit.empty({ icon: 'grid', title: 'The catalog is filling up', text: 'Plans arrive from providers\' official APIs as each source is checked and enabled, and from staff entries with their source pages.' });const next = result.next_cursor ? `<p class="hc-more">${link(`/catalog?${new URLSearchParams({ ...query, cursor: result.next_cursor })}`, 'Next 50 plans →', 'button hc-button-quiet')}</p>` : '';// The catalog filter already has a Dedicated kind; when there are dedicated plans, the comparison page also// shows them as their own section, with the same table. A kind=dedicated view is that table, so no second copy.@@@ -401,8 +401,8 @@ ${input('max_monthly', 'Max a month', q.max_monthly, 'type="number" min="0" step<div class="hc-filter-actions"><button type="submit">Show offers</button> <a href="/catalog/offers" class="hc-reset">Reset</a></div></form>${list.length ? `<ol class="hc-offers" aria-label="${list.length}${result.next_cursor ? '+' : ''} offers">${list.map((o) => offerItem(o, now)).join('')}</ol>`-- : filtered ? `<div class="hc-empty"><p><strong>No offer matches these filters.</strong></p><p>${link('/catalog/offers', 'Clear the filters')} or widen one of them.</p></div>`-- : '<div class="hc-empty"><p><strong>No offer in the last 120 days.</strong></p><p>LowEndSpirit\'s Offers board is read every hour.</p></div>'}++ : filtered ? kit.empty({ icon: 'search', title: 'No offer matches these filters', text: 'Widen one of the filters to see more offers.', action: { href: '/catalog/offers', label: 'Clear the filters', icon: 'grid' } })++ : kit.empty({ icon: 'tag', title: 'No offer in the last 120 days', text: 'LowEndSpirit\'s Offers board is read every hour.', action: { href: '/catalog', label: 'Compare the plans', icon: 'grid' } })}${next}<section class="hc-method" aria-labelledby="hc-offers-method-h"><h2 id="hc-offers-method-h">How offers are read</h2><ul><li><strong>From the board's own feed.</strong> LowEndSpirit's Offers board, where providers post their offers, through OpenVibe.Sources. Each headline links to its post, which has the details and the final word.</li>@@@ -60,6 +60,8 @@ const { boot, check, done } = require('./helpers/boot');assert.ok(front.text.includes(`href="/catalog/providers/${p.slug}"`), 'a provider card opens its page');assert.strictEqual((await t.api('GET', '/catalog/providers')).status, 200);const nothing = await t.api('GET', '/catalog?min_vcpu=999'); assert.match(nothing.text, /No plan matches these filters/);++ assert.match(nothing.text, /class="ovk-empty"/, 'an empty catalog draws the kit empty state');++ assert.match(nothing.text, /class="ovk-btn[^"]*" href="\/catalog"/, 'the empty state offers the button that clears the filters');const bad = await t.api('GET', '/catalog?sort=affiliate'); assert.strictEqual(bad.status, 422);// JSON-LD on the indexable catalog pages: WebSite and an ItemList on the front, an ItemList on /catalog,// a Product with a monthly Offer on a plan; never on a filtered (noindex) view.
@@ -94,6 +94,7 @@ function createCatalogViews({ catalog, benchmarks, community, config, log = cons
@@ -94,6 +94,7 @@ function createCatalogViews({ catalog, benchmarks, community, config, log = cons
return {
return {
title: 'Compare hosting plans',
title: 'Compare hosting plans',
body: render.catalog({ result, query, providers, currencies, dedicated }),
body: render.catalog({ result, query, providers, currencies, dedicated }),
+ styles: ['kit.css'],
indexable: !Object.keys(query).length,
indexable: !Object.keys(query).length,
description: 'Compare cloud, VPS and dedicated hosting plans: price per month, per vCPU and per GB, each with the day it was checked and its source.',
description: 'Compare cloud, VPS and dedicated hosting plans: price per month, per vCPU and per GB, each with the day it was checked and its source.',
jsonLd: render.jsonLd.catalog(baseUrl, result.plans),
jsonLd: render.jsonLd.catalog(baseUrl, result.plans),
@@ -144,7 +145,7 @@ function createCatalogViews({ catalog, benchmarks, community, config, log = cons
@@ -144,7 +145,7 @@ function createCatalogViews({ catalog, benchmarks, community, config, log = cons
if (q.sort === 'price' && !q.currency) q.currency = 'USD';
if (q.sort === 'price' && !q.currency) q.currency = 'USD';
const result = await catalog.offers(q);
const result = await catalog.offers(q);
return {
return {
- title: 'Hosting offers', body: render.offers({ result, query: q }), indexable: !Object.keys(query).length,
+ title: 'Hosting offers', body: render.offers({ result, query: q }), indexable: !Object.keys(query).length, styles: ['kit.css'],
description: "Hosting offers from LowEndSpirit's Offers board, newest first or cheapest a month, each with the price its headline states and the day it was posted.",
description: "Hosting offers from LowEndSpirit's Offers board, newest first or cheapest a month, each with the price its headline states and the day it was posted.",
};
};
}
}
@@ -228,8 +228,8 @@ function catalog({ result, query, providers, currencies, dedicated = { plans: []
@@ -228,8 +228,8 @@ function catalog({ result, query, providers, currencies, dedicated = { plans: []
const plans = result.plans;
const plans = result.plans;
const filtered = Object.keys(query || {}).some((k) => k !== 'sort' && k !== 'cursor' && query[k]);
const filtered = Object.keys(query || {}).some((k) => k !== 'sort' && k !== 'cursor' && query[k]);
const empty = plans.length ? '' : filtered
const empty = plans.length ? '' : filtered
- ? `<div class="hc-empty"><p><strong>No plan matches these filters.</strong></p><p>${link('/catalog', 'Clear the filters')} or widen one of them.</p></div>`
- : `<div class="hc-empty"><p><strong>The catalog is filling up.</strong></p><p>Plans arrive from providers' official APIs as each source is checked and enabled, and from staff entries with their source pages.</p></div>`;
+ ? kit.empty({ icon: 'search', title: 'No plan matches these filters', text: 'Widen one of the filters to see more plans.', action: { href: '/catalog', label: 'Clear the filters', icon: 'grid' } })
+ : kit.empty({ icon: 'grid', title: 'The catalog is filling up', text: 'Plans arrive from providers\' official APIs as each source is checked and enabled, and from staff entries with their source pages.' });
const next = result.next_cursor ? `<p class="hc-more">${link(`/catalog?${new URLSearchParams({ ...query, cursor: result.next_cursor })}`, 'Next 50 plans →', 'button hc-button-quiet')}</p>` : '';
const next = result.next_cursor ? `<p class="hc-more">${link(`/catalog?${new URLSearchParams({ ...query, cursor: result.next_cursor })}`, 'Next 50 plans →', 'button hc-button-quiet')}</p>` : '';
// The catalog filter already has a Dedicated kind; when there are dedicated plans, the comparison page also
// The catalog filter already has a Dedicated kind; when there are dedicated plans, the comparison page also
// shows them as their own section, with the same table. A kind=dedicated view is that table, so no second copy.
// shows them as their own section, with the same table. A kind=dedicated view is that table, so no second copy.
@@ -401,8 +401,8 @@ ${input('max_monthly', 'Max a month', q.max_monthly, 'type="number" min="0" step
@@ -401,8 +401,8 @@ ${input('max_monthly', 'Max a month', q.max_monthly, 'type="number" min="0" step
<div class="hc-filter-actions"><button type="submit">Show offers</button> <a href="/catalog/offers" class="hc-reset">Reset</a></div>
<div class="hc-filter-actions"><button type="submit">Show offers</button> <a href="/catalog/offers" class="hc-reset">Reset</a></div>
</form>
</form>
${list.length ? `<ol class="hc-offers" aria-label="${list.length}${result.next_cursor ? '+' : ''} offers">${list.map((o) => offerItem(o, now)).join('')}</ol>`
${list.length ? `<ol class="hc-offers" aria-label="${list.length}${result.next_cursor ? '+' : ''} offers">${list.map((o) => offerItem(o, now)).join('')}</ol>`
- : filtered ? `<div class="hc-empty"><p><strong>No offer matches these filters.</strong></p><p>${link('/catalog/offers', 'Clear the filters')} or widen one of them.</p></div>`
- : '<div class="hc-empty"><p><strong>No offer in the last 120 days.</strong></p><p>LowEndSpirit\'s Offers board is read every hour.</p></div>'}
+ : filtered ? kit.empty({ icon: 'search', title: 'No offer matches these filters', text: 'Widen one of the filters to see more offers.', action: { href: '/catalog/offers', label: 'Clear the filters', icon: 'grid' } })
+ : kit.empty({ icon: 'tag', title: 'No offer in the last 120 days', text: 'LowEndSpirit\'s Offers board is read every hour.', action: { href: '/catalog', label: 'Compare the plans', icon: 'grid' } })}
${next}
${next}
<section class="hc-method" aria-labelledby="hc-offers-method-h"><h2 id="hc-offers-method-h">How offers are read</h2><ul>
<section class="hc-method" aria-labelledby="hc-offers-method-h"><h2 id="hc-offers-method-h">How offers are read</h2><ul>
<li><strong>From the board's own feed.</strong> LowEndSpirit's Offers board, where providers post their offers, through OpenVibe.Sources. Each headline links to its post, which has the details and the final word.</li>
<li><strong>From the board's own feed.</strong> LowEndSpirit's Offers board, where providers post their offers, through OpenVibe.Sources. Each headline links to its post, which has the details and the final word.</li>
@@ -60,6 +60,8 @@ const { boot, check, done } = require('./helpers/boot');
@@ -60,6 +60,8 @@ const { boot, check, done } = require('./helpers/boot');
assert.ok(front.text.includes(`href="/catalog/providers/${p.slug}"`), 'a provider card opens its page');
assert.ok(front.text.includes(`href="/catalog/providers/${p.slug}"`), 'a provider card opens its page');
assert.strictEqual((await t.api('GET', '/catalog/providers')).status, 200);
assert.strictEqual((await t.api('GET', '/catalog/providers')).status, 200);
const nothing = await t.api('GET', '/catalog?min_vcpu=999'); assert.match(nothing.text, /No plan matches these filters/);
const nothing = await t.api('GET', '/catalog?min_vcpu=999'); assert.match(nothing.text, /No plan matches these filters/);
+ assert.match(nothing.text, /class="ovk-empty"/, 'an empty catalog draws the kit empty state');
+ assert.match(nothing.text, /class="ovk-btn[^"]*" href="\/catalog"/, 'the empty state offers the button that clears the filters');
const bad = await t.api('GET', '/catalog?sort=affiliate'); assert.strictEqual(bad.status, 422);
const bad = await t.api('GET', '/catalog?sort=affiliate'); assert.strictEqual(bad.status, 422);
// JSON-LD on the indexable catalog pages: WebSite and an ItemList on the front, an ItemList on /catalog,
// JSON-LD on the indexable catalog pages: WebSite and an ItemList on the front, an ItemList on /catalog,
// a Product with a monthly Offer on a plan; never on a filtered (noindex) view.
// a Product with a monthly Offer on a plan; never on a filtered (noindex) view.