openvibers/openvibe.host Commits
Host: "Tell me when this price changes" on every plan page (a prefilled OpenVibe.Watch watch) (#83)
Changes
@@@ -127,3 +127,5 @@ HOST_CATALOG_WEBHOOK_SECRETS=HOST_COMMUNITY_URL=# Public Community address used for the join link.OV_COMMUNITY_URL=https://openvibe.community++# OpenVibe.Watch's public site: a plan page links a prefilled watch on the plan's own API ("Tell me when this price changes").++OV_WATCH_URL=https://openvibe.watch@@@ -139,3 +139,4 @@}/* Filled buttons on the readable accent (white on --accent-strong passes AA; dark text on the light accent does not everywhere). */.hc-visit .button:not(.hc-button-quiet), .hc-more .button:not(.hc-button-quiet) { background: var(--accent-strong, #2563eb); color: var(--on-accent-strong, #fff); }++.hc-watch { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem .8rem; margin: .9rem 0 0; }@@@ -94,6 +94,8 @@ function load(env = process.env) {internalUrl: trim(env.HOST_COMMUNITY_URL || ''),publicUrl: trim(env.OV_COMMUNITY_URL || 'https://openvibe.community'),},++ // OpenVibe.Watch's public site: a plan page links a prefilled watch on the plan's own API (no call is made).++ watch: { publicUrl: trim(env.OV_WATCH_URL || 'https://openvibe.watch') },uploads: {// Largest request body accepted for one deploy (the compressed archive or all multipart parts).@@@ -80,7 +80,7 @@ function createCatalogPages({ catalog, community, config, viewers, actorLimits,router.get('/plans/:id', view(async (req, res) => {const p = await catalog.plan(req.params.id);const discussion = await discussionFor(req, 'plan', p, `${p.provider_name} ${p.name}`);-- page(req, res, `${p.provider_name} ${p.name}`, render.plan(p, discussion), true, `${p.provider_name} ${p.name}: ${p.currency} ${p.price_monthly} a month, with its specifications, price per resource and price history.`, render.jsonLd.plan(config.baseUrl, p));++ page(req, res, `${p.provider_name} ${p.name}`, render.plan(p, discussion, { baseUrl: config.baseUrl, watchUrl: config.watch && config.watch.publicUrl }), true, `${p.provider_name} ${p.name}: ${p.currency} ${p.price_monthly} a month, with its specifications, price per resource and price history.`, render.jsonLd.plan(config.baseUrl, p));}));router.get('/promos', view(async (req, res) => {page(req, res, 'Hosting promotions', render.promos(await catalog.promos()), true, 'Hosting promotions running now, with their codes and end dates as the providers state them.');@@@ -195,7 +195,7 @@ ${promoCards(p.promos, { showProvider: false })}${p.affiliate_url || p.promos.some((x) => x.affiliate) ? `<p class="hc-disclosure">${esc(DISCLOSURE)}</p>` : ''}`;}--function plan(p, discussion = null) {++function plan(p, discussion = null, { baseUrl = '', watchUrl = '' } = {}) {const spec = [['vCPU', p.vcpu == null ? null : num(p.vcpu)], ['RAM', bytesMb(p.ram_mb)],['Disk', p.disk_gb == null ? null : `${sizeGb(p.disk_gb)}${p.disk_type ? ` ${p.disk_type.toUpperCase()}` : ''}`],@@@ -221,6 +221,7 @@ ${visit(p)}</div><h2>Price history</h2>${history}++${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>` : ''}${discussionSection(discussion)}${p.affiliate_url ? `<p class="hc-disclosure">${esc(DISCLOSURE)}</p>` : ''}<p class="hc-links">${link('/catalog', '← Compare plans')}</p>`;@@@ -52,6 +52,7 @@ const { boot, check, done } = require('./stageb/boot');const planPage = await t.api('GET', `/catalog/plans/${cheap.id}`);const product = ld(planPage.text).find((x) => x['@type'] === 'Product');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&url=[^"]*%2Fapi%2Fv1%2Fcatalog%2Fplans%2F[^"]*value_path=plan\.price_monthly&op=changed/, 'a plan page offers a prefilled price watch');assert.ok(product && product.offers.price === '4.00' && product.offers.priceCurrency === 'USD' && product.offers.priceSpecification.unitText === 'MONTH');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);
@@ -127,3 +127,5 @@ HOST_CATALOG_WEBHOOK_SECRETS=
@@ -127,3 +127,5 @@ HOST_CATALOG_WEBHOOK_SECRETS=
HOST_COMMUNITY_URL=
HOST_COMMUNITY_URL=
# Public Community address used for the join link.
# Public Community address used for the join link.
OV_COMMUNITY_URL=https://openvibe.community
OV_COMMUNITY_URL=https://openvibe.community
+# OpenVibe.Watch's public site: a plan page links a prefilled watch on the plan's own API ("Tell me when this price changes").
+OV_WATCH_URL=https://openvibe.watch
@@ -139,3 +139,4 @@
@@ -139,3 +139,4 @@
}
}
/* Filled buttons on the readable accent (white on --accent-strong passes AA; dark text on the light accent does not everywhere). */
/* Filled buttons on the readable accent (white on --accent-strong passes AA; dark text on the light accent does not everywhere). */
.hc-visit .button:not(.hc-button-quiet), .hc-more .button:not(.hc-button-quiet) { background: var(--accent-strong, #2563eb); color: var(--on-accent-strong, #fff); }
.hc-visit .button:not(.hc-button-quiet), .hc-more .button:not(.hc-button-quiet) { background: var(--accent-strong, #2563eb); color: var(--on-accent-strong, #fff); }
+.hc-watch { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem .8rem; margin: .9rem 0 0; }
@@ -94,6 +94,8 @@ function load(env = process.env) {
@@ -94,6 +94,8 @@ function load(env = process.env) {
internalUrl: trim(env.HOST_COMMUNITY_URL || ''),
internalUrl: trim(env.HOST_COMMUNITY_URL || ''),
publicUrl: trim(env.OV_COMMUNITY_URL || 'https://openvibe.community'),
publicUrl: trim(env.OV_COMMUNITY_URL || 'https://openvibe.community'),
},
},
+ // OpenVibe.Watch's public site: a plan page links a prefilled watch on the plan's own API (no call is made).
+ watch: { publicUrl: trim(env.OV_WATCH_URL || 'https://openvibe.watch') },
uploads: {
uploads: {
// Largest request body accepted for one deploy (the compressed archive or all multipart parts).
// Largest request body accepted for one deploy (the compressed archive or all multipart parts).
@@ -80,7 +80,7 @@ function createCatalogPages({ catalog, community, config, viewers, actorLimits,
@@ -80,7 +80,7 @@ function createCatalogPages({ catalog, community, config, viewers, actorLimits,
router.get('/plans/:id', view(async (req, res) => {
router.get('/plans/:id', view(async (req, res) => {
const p = await catalog.plan(req.params.id);
const p = await catalog.plan(req.params.id);
const discussion = await discussionFor(req, 'plan', p, `${p.provider_name} ${p.name}`);
const discussion = await discussionFor(req, 'plan', p, `${p.provider_name} ${p.name}`);
- page(req, res, `${p.provider_name} ${p.name}`, render.plan(p, discussion), true, `${p.provider_name} ${p.name}: ${p.currency} ${p.price_monthly} a month, with its specifications, price per resource and price history.`, render.jsonLd.plan(config.baseUrl, p));
+ page(req, res, `${p.provider_name} ${p.name}`, render.plan(p, discussion, { baseUrl: config.baseUrl, watchUrl: config.watch && config.watch.publicUrl }), true, `${p.provider_name} ${p.name}: ${p.currency} ${p.price_monthly} a month, with its specifications, price per resource and price history.`, render.jsonLd.plan(config.baseUrl, p));
}));
}));
router.get('/promos', view(async (req, res) => {
router.get('/promos', view(async (req, res) => {
page(req, res, 'Hosting promotions', render.promos(await catalog.promos()), true, 'Hosting promotions running now, with their codes and end dates as the providers state them.');
page(req, res, 'Hosting promotions', render.promos(await catalog.promos()), true, 'Hosting promotions running now, with their codes and end dates as the providers state them.');
@@ -195,7 +195,7 @@ ${promoCards(p.promos, { showProvider: false })}
@@ -195,7 +195,7 @@ ${promoCards(p.promos, { showProvider: false })}
${p.affiliate_url || p.promos.some((x) => x.affiliate) ? `<p class="hc-disclosure">${esc(DISCLOSURE)}</p>` : ''}`;
${p.affiliate_url || p.promos.some((x) => x.affiliate) ? `<p class="hc-disclosure">${esc(DISCLOSURE)}</p>` : ''}`;
}
}
-function plan(p, discussion = null) {
+function plan(p, discussion = null, { baseUrl = '', watchUrl = '' } = {}) {
const spec = [
const spec = [
['vCPU', p.vcpu == null ? null : num(p.vcpu)], ['RAM', bytesMb(p.ram_mb)],
['vCPU', p.vcpu == null ? null : num(p.vcpu)], ['RAM', bytesMb(p.ram_mb)],
['Disk', p.disk_gb == null ? null : `${sizeGb(p.disk_gb)}${p.disk_type ? ` ${p.disk_type.toUpperCase()}` : ''}`],
['Disk', p.disk_gb == null ? null : `${sizeGb(p.disk_gb)}${p.disk_type ? ` ${p.disk_type.toUpperCase()}` : ''}`],
@@ -221,6 +221,7 @@ ${visit(p)}
@@ -221,6 +221,7 @@ ${visit(p)}
</div>
</div>
<h2>Price history</h2>
<h2>Price history</h2>
${history}
${history}
+${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>` : ''}
${discussionSection(discussion)}
${discussionSection(discussion)}
${p.affiliate_url ? `<p class="hc-disclosure">${esc(DISCLOSURE)}</p>` : ''}
${p.affiliate_url ? `<p class="hc-disclosure">${esc(DISCLOSURE)}</p>` : ''}
<p class="hc-links">${link('/catalog', '← Compare plans')}</p>`;
<p class="hc-links">${link('/catalog', '← Compare plans')}</p>`;
@@ -52,6 +52,7 @@ const { boot, check, done } = require('./stageb/boot');
@@ -52,6 +52,7 @@ const { boot, check, done } = require('./stageb/boot');
const planPage = await t.api('GET', `/catalog/plans/${cheap.id}`);
const planPage = await t.api('GET', `/catalog/plans/${cheap.id}`);
const product = ld(planPage.text).find((x) => x['@type'] === 'Product');
const product = ld(planPage.text).find((x) => x['@type'] === 'Product');
assert.match(planPage.text, /class="hc-visit"/, 'a plan page offers a way to the provider');
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&url=[^"]*%2Fapi%2Fv1%2Fcatalog%2Fplans%2F[^"]*value_path=plan\.price_monthly&op=changed/, 'a plan page offers a prefilled price watch');
assert.ok(product && product.offers.price === '4.00' && product.offers.priceCurrency === 'USD' && product.offers.priceSpecification.unitText === 'MONTH');
assert.ok(product && product.offers.price === '4.00' && product.offers.priceCurrency === 'USD' && product.offers.priceSpecification.unitText === 'MONTH');
assert.ok(!types((await t.api('GET', '/catalog?min_vcpu=2')).text).includes('ItemList'), 'a filtered view carries no JSON-LD');
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);
const promos = await t.api('GET', '/api/v1/catalog/promos'); assert.strictEqual(promos.json().promos.length, 1);