openvibers/openvibe.host Commits

Host: "Tell me when this price changes" on every plan page (a prefilled OpenVibe.Watch watch) (#83)

OP OpenVibers committed 12 Oct 2026 · 50037b0cf0 · 6 files, +9 −2

Every plan page gets "Tell me when this price changes", a link under the price history to OpenVibe.Watch with the form already filled in (Watch#48):

- template `json`;
- the plan's public API URL (`/api/v1/catalog/plans/<id>`);
- `value_path=plan.price_monthly` and `op=changed`;
- a name like "Linode Nanode 1GB price".

The person reviews and creates the watch on Watch, and Watch then checks the plan's own API. Host makes no call. Config: `OV_WATCH_URL`, default https://openvibe.watch.

Test: the plan page carries the prefilled link (`test/catalog-public.test.js`). The perf budget is unchanged.

Changes

ddiff --git a/.env.example b/.env.example
iindex 0138cef..a03cb1d 100644
---- a/.env.example
++++ b/.env.example
@@@ -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
ddiff --git a/public/css/catalog.css b/public/css/catalog.css
iindex 70a70bd..5e3b199 100644
---- a/public/css/catalog.css
++++ b/public/css/catalog.css
@@@ -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; }
ddiff --git a/server/config.js b/server/config.js
iindex b8fd485..a475c88 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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).
ddiff --git a/server/http/catalog.js b/server/http/catalog.js
iindex 1984c31..3465b57 100644
---- a/server/http/catalog.js
++++ b/server/http/catalog.js
@@@ -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.');
ddiff --git a/server/render/catalog.js b/server/render/catalog.js
iindex a469440..b17e8fe 100644
---- a/server/render/catalog.js
++++ b/server/render/catalog.js
@@@ -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>`;
ddiff --git a/test/catalog-public.test.js b/test/catalog-public.test.js
iindex 441c832..5186dea 100644
---- a/test/catalog-public.test.js
++++ b/test/catalog-public.test.js
@@@ -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&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.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);