openvibers/openvibe.vip Commits
VIP's home shows what is real: creators with a membership who are live now, every creator with their avatar, a path for creators to offer a plan, and the questions people ask (with FAQPage data) (#75)
Changes
@@@ -51,8 +51,11 @@ details > summary { cursor: pointer; font-weight: 600; margin: 6px 0; }.pill { display: inline-block; border: 1px solid var(--vip-border); border-radius: 999px; padding: 0 10px; font-size: .85rem; color: var(--vip-muted); }.pill-on { border-color: var(--vip-good); color: var(--vip-good); }.price b { font-size: 1.15rem; }--.creator-list { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; }--.creator-list a { display: flex; flex-direction: column; background: var(--vip-card); border: 1px solid var(--vip-border); border-radius: 10px; padding: 12px 14px; color: inherit; text-decoration: none; }++.creator-list { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(min(220px, 100%), 1fr)); gap: 12px; }++.creator-list a { display: flex; align-items: center; gap: 12px; height: 100%; background: var(--vip-card); border: 1px solid var(--vip-border); border-radius: 10px; padding: 10px 14px; color: inherit; text-decoration: none; transition: border-color .15s; }++.creator-list a:hover, .creator-list a:focus-visible { border-color: var(--vip-accent); }++.vip-creator-text { display: flex; flex-direction: column; min-width: 0; flex: 1; }++.vip-creator-text b, .vip-creator-text span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.perks, .benefits, .versions, .rules, .perk-admin { padding-left: 1.2em; }.perk-admin { list-style: none; padding: 0; }table.members { width: 100%; border-collapse: collapse; display: block; overflow-x: auto; }@@@ -66,3 +69,22 @@ code { background: rgba(255, 255, 255, .06); padding: 1px 5px; border-radius: 4p@media (min-width: 640px) { .page .sc-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }.page #creators .creator-list { margin-top: 12px; }.page .creators-cta { margin-top: 14px; }++++/* The home: the numbers beside the hero, creators live now, avatars, questions. */++.vip-avatar { flex: none; border-radius: 50%; background: var(--vip-border); object-fit: cover; }++.vip-stats { margin: 0; display: grid; gap: 10px; }++.vip-stats div { background: var(--vip-card); border: 1px solid var(--vip-border); border-radius: 12px; padding: 12px 16px; }++.vip-stats dt { font-size: 1.25rem; font-weight: 700; }++.vip-stats dd { margin: 2px 0 0; color: var(--vip-muted); font-size: .92rem; }++.vip-live-pill { flex: none; display: inline-block; border-radius: 999px; padding: 0 8px; font-size: .75rem; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; background: var(--vip-bad); color: #1a0505; }++.vip-live { list-style: none; padding: 0; margin: 12px 0 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(min(260px, 100%), 1fr)); gap: 12px; }++.vip-live li { display: flex; gap: 12px; align-items: flex-start; background: var(--vip-card); border: 1px solid var(--vip-border); border-radius: 12px; padding: 12px 14px; }++.vip-live-main { min-width: 0; flex: 1; }++.vip-live-main p { margin: 0 0 4px; }++.vip-live-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.vip-live-links { display: flex; flex-wrap: wrap; gap: 6px 14px; margin-top: 6px !important; font-weight: 600; }++.vip-faq { margin-top: 12px; display: grid; gap: 8px; }++.vip-faq details { background: var(--vip-card); border: 1px solid var(--vip-border); border-radius: 10px; padding: 4px 16px; }++.vip-faq details[open] { padding-bottom: 12px; }++.vip-faq summary { padding: 8px 0; margin: 0; }++.vip-faq p { margin: 4px 0 0; color: var(--vip-muted); }@@@ -13,7 +13,7 @@* /embed/:username/card.json|badge.svg|widget public card, badge and widget (no cookies, see web/embeds.js)* /, /me, /dashboard, /:username … server-rendered pages*-- * createApp({ config, db, keys, billing, outbox, now, fetchImpl, eventsFetch, log }) — all injectable.++ * createApp({ config, db, keys, billing, outbox, now, fetchImpl, eventsFetch, liveNow, log }) — all injectable.*/const path = require('path');const express = require('express');@@@ -135,7 +135,8 @@ async function createApp(opts = {}) {res.setHeader('Cache-Control', cache.assetHeaders(rel, { hashed: v && v === assetVersion(rel) }));},}));-- app.use(createWebRoutes({ domain, config, layout, userAuth }));++ const liveNow = opts.liveNow || require('./live-now').createLiveNow({ config, fetchImpl, log });++ app.use(createWebRoutes({ domain, config, layout, userAuth, liveNow }));app.use((req, res) => {if (req.path.startsWith('/api/') || req.path.startsWith('/internal/')) return http.sendProblem(res, 404, 'not_found', { ctx: req.ov });@@@ -89,6 +89,8 @@ function loadConfig(env = process.env) {refreshIntervalMs: int(env.VIP_REFRESH_INTERVAL_MS, 60 * 1000),},liveUrl: trim(env.LIVE_URL || 'https://openvibe.live'),++ // The home's "Live now" strip reads Live's public stream list (server/live-now.js); off in tests unless asked.++ liveNow: env.VIP_LIVE_NOW ? env.VIP_LIVE_NOW === 'on' : nodeEnv !== 'test',// The public card, badge and widget (/embed, server/web/embeds.js). frameAncestors is the// widget's CSP frame-ancestors (any site by default: it is meant to be embedded; the rest ofnnew file mode 100644@@@ -0,0 +1,62 @@++'use strict';++++/**++ * Who is live on OpenVibe.Live right now, for the home's "Live now" strip (creators with a plan who are live): Live's++ * public stream list (GET <LIVE_URL>/api/streams, the list its own home shows), read at most once a minute and kept in++ * memory. Only what a card needs is kept: the channel's name, title, category, viewers and start time; never Live's AI++ * text.++ *++ * A slow or failing Live never slows or breaks the page: the call times out after 2 seconds and the last good list (or++ * none) is used. Off in tests unless VIP_LIVE_NOW=on (config.liveNow). The same client as OpenVibe.Tips'++ * server/clients/live-now.js, keeping every live channel (the home keeps the ones with a plan).++ */++const TTL_MS = 60_000;++const TIMEOUT_MS = 2_000;++const MAX = 100;++++const str = (v, max) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, max) : null);++++function createLiveNow({ config, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console } = {}) {++ let cache = { at: 0, list: [] };++ let inflight = null;++++ function shape(rows) {++ return (Array.isArray(rows) ? rows : []).filter((s) => s && (s.is_live === 1 || s.is_live === true) && !s.is_nsfw && /^[A-Za-z0-9_-]{1,40}$/.test(String(s.username || '')))++ .sort((a, b) => (Number(b.viewer_count) || 0) - (Number(a.viewer_count) || 0))++ .slice(0, MAX)++ .map((s) => ({++ username: String(s.username), name: str(s.display_name, 60) || String(s.username), title: str(s.title, 120),++ category: str(s.chosen_category || s.category, 40), viewers: Math.max(0, Number(s.viewer_count) || 0),++ started_at: str(s.started_at, 40), url: `${config.liveUrl}/@${encodeURIComponent(String(s.username))}`,++ }));++ }++++ async function refresh() {++ const ctrl = new AbortController();++ const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);++ try {++ const res = await fetchImpl(`${config.liveUrl}/api/streams`, { headers: { Accept: 'application/json' }, signal: ctrl.signal });++ if (!res.ok) throw new Error(`Live answered ${res.status}`);++ const body = await res.json();++ cache = { at: now(), list: shape(body && body.streams) };++ } catch (err) {++ cache = { at: now(), list: cache.list }; // keep the last good list; try again after the TTL++ log.warn(`[VIP] live-now: ${err.message}`);++ } finally {++ clearTimeout(timer);++ }++ return cache.list;++ }++++ return {++ /** The channels live now, the most watched first (at most 100); [] when Live is off or unreachable. */++ async list() {++ if (!config.liveNow || !config.liveUrl) return [];++ if (now() - cache.at < TTL_MS) return cache.list;++ inflight = inflight || refresh().finally(() => { inflight = null; });++ return await inflight;++ },++ };++}++++module.exports = { createLiveNow, TTL_MS };@@@ -45,23 +45,68 @@ function errorPage({ status, title, message }) {return `<section class="card narrow"><p class="eyebrow">${esc(status)}</p><h1>${esc(title)}</h1><p>${esc(message)}</p><p><a href="/">OpenVibe.VIP home</a></p></section>`;}++/** The home's title and description (layout adds " · OpenVibe.VIP"). */++const HOME_TITLE = 'Creator memberships across OpenVibe';++const HOME_DESCRIPTION = "Back the creators you watch with a monthly membership, recognised on every OpenVibe site. Or sell one for your channel or your software.";++++/** The questions the home answers, shown on the page and given to search engines as FAQPage data (the same text). */++const HOME_FAQ = [++ { q: 'What is a membership?', a: "A monthly plan from one creator. The plan lists what it includes, such as a members badge by your name in chat, members-only posts and a members' room, and each OpenVibe site that honours a perk recognises it." },++ { q: 'How do I pay, and can I stop?', a: 'OpenVibe.Billing charges and renews every membership. Pay from your Vibes credit or another way Billing offers, and turn renewal off whenever you like: you keep the membership until the end of the period you paid for.' },++ { q: 'What if the creator changes the plan?', a: 'Plans are versioned. The version you joined stays yours while your membership lasts, and every version stays public in the plan\'s terms history.' },++ { q: 'Is a channel subscription on OpenVibe.Live the same thing?', a: "Yes. A creator's channel subscription on OpenVibe.Live is their plan here: the same Billing subscription, with its terms and perks written down." },++ { q: 'How do I offer a plan?', a: 'Sign in and open your dashboard. Choose a tier from 1 to 10 and its monthly price, pick the perks, and publish. Billing takes the payments and your earnings follow Billing\'s payouts.' },++ { q: 'Can I sell a subscription to my own app?', a: 'Yes. Publish a plan, then have your app ask VIP whether the person signed in with OpenVibe is a member, with the SDK or one HTTPS call. The answer is no as soon as a membership ends.' },++];++++/** FAQPage structured data for the home, from the same questions the page shows. */++function homeFaqJsonLd() {++ return {++ '@context': 'https://schema.org', '@type': 'FAQPage',++ mainEntity: HOME_FAQ.map((f) => ({ '@type': 'Question', name: f.q, acceptedAnswer: { '@type': 'Answer', text: f.a } })),++ };++}++++const avatar = (networkUrl, username, size) => `<img class="vip-avatar" src="${esc(networkUrl)}/avatar/${encodeURIComponent(username)}?s=${size}" width="${size}" height="${size}" alt="" loading="lazy" decoding="async">`;++const plural = (n, one, many) => `${n.toLocaleString('en-US')} ${n === 1 ? one : many}`;++/**-- * The home: the product's own words (openvibe-shared/showcase), then the creators with published plans. No prices here:-- * OpenVibe.Billing prices each plan, and a creator's page shows it before anyone joins.++ * The home: who it is for in one line, the real numbers beside it, creators with a plan who are live on OpenVibe.Live++ * right now, every creator with a plan (live ones first), what a plan can include, how a membership works, how a creator++ * offers one, selling a subscription to software, and the questions people ask. No prices here: OpenVibe.Billing prices++ * each plan, and a creator's page shows it before anyone joins. live: server/live-now.js (Live's public stream list).*/--function home({ creators }) {-- const list = creators.length-- ? `<ul class="creator-list">${creators.map((c) => `<li><a href="/${encodeURIComponent(c.username)}"><b>${esc(c.display_name || c.username)}</b><span class="muted">@${esc(c.username)}</span></a></li>`).join('')}</ul>`++function home({ creators, live = [], networkUrl = 'https://openvibe.network' }) {++ const onAir = new Map(live.map((s) => [String(s.username).toLowerCase(), s]));++ const liveCreators = creators.filter((c) => c.username && onAir.has(c.username.toLowerCase())).map((c) => ({ c, s: onAir.get(c.username.toLowerCase()) }));++ const ordered = [...liveCreators.map((x) => x.c), ...creators.filter((c) => !c.username || !onAir.has(c.username.toLowerCase()))];++++ const stats = [];++ if (creators.length) stats.push(`<div><dt>${plural(creators.length, 'creator', 'creators')}</dt><dd>offer a membership</dd></div>`);++ if (liveCreators.length) stats.push(`<div><dt>${plural(liveCreators.length, 'creator', 'creators')}</dt><dd>live on OpenVibe.Live now</dd></div>`);++ stats.push('<div><dt>10 tiers</dt><dd>each creator prices their own</dd></div>');++ stats.push('<div><dt>1 membership</dt><dd>recognised on every OpenVibe site that honours it</dd></div>');++++ const liveSection = liveCreators.length ? `++<section class="sc-sec" id="live" aria-labelledby="live-h"><h2 id="live-h">Live now</h2><p class="sc-lede">Creators with a membership who are streaming on OpenVibe.Live right now.</p>++<ul class="vip-live">${liveCreators.map(({ c, s }) => `<li>${avatar(networkUrl, c.username, 48)}<div class="vip-live-main"><p class="vip-live-name"><b>${esc(c.display_name || c.username)}</b> <span class="vip-live-pill">Live</span></p>${s.title ? `<p class="vip-live-title">${esc(s.title)}</p>` : ''}<p class="muted small">${s.category ? `${esc(s.category)} · ` : ''}${plural(s.viewers, 'viewer', 'viewers')}</p><p class="vip-live-links"><a href="${esc(s.url)}" rel="noopener">Watch</a><a href="/${encodeURIComponent(c.username)}">See the plan</a></p></div></li>`).join('')}</ul></section>` : '';++++ const list = ordered.length++ ? `<ul class="creator-list">${ordered.map((c) => `<li><a href="/${encodeURIComponent(c.username)}">${avatar(networkUrl, c.username, 40)}<span class="vip-creator-text"><b>${esc(c.display_name || c.username)}</b><span class="muted">@${esc(c.username)}</span></span>${onAir.has(c.username.toLowerCase()) ? '<span class="vip-live-pill">Live</span>' : ''}</a></li>`).join('')}</ul>`: '<p class="muted">No creator has published a plan here yet.</p>';++return showcase.hero({eyebrow: 'OpenVibe.VIP',title: 'Memberships across', accent: 'OpenVibe',-- lede: "Join a creator's plan once and your membership is recognised on every OpenVibe site that honours it: a badge in chat, members-only posts, rooms and more, as each plan lists.",++ lede: "Back the creators you watch with a monthly membership. Join once and it's recognised across OpenVibe: a badge by your name in chat, members-only posts, a members' room, whatever the plan lists.",actions: creators.length-- ? [{ label: 'Find a creator', href: '#creators', primary: true }, { label: 'Your memberships', href: '/me' }]++ ? [{ label: 'Find a creator', href: '#creators', primary: true }, { label: 'Offer a plan', href: '#for-creators' }, { label: 'Your memberships', href: '/me' }]: [{ label: 'Offer a plan', href: '/dashboard', primary: true }, { label: 'Your memberships', href: '/me' }],note: 'Payments, renewals, cancellations and refunds are handled by OpenVibe.Billing.',-- }) + showcase.features({++ html: `<dl class="vip-stats">${stats.join('')}</dl>`,++ }) + liveSection + `++<section class="sc-sec" id="creators" aria-labelledby="creators-h"><h2 id="creators-h">Creators with a membership</h2><p class="sc-lede">Each page lists the creator's plan, what it includes and its price before you join.</p>${list}<p class="muted creators-cta">Are you a creator? <a href="/dashboard">Offer a plan</a>.</p></section>`++ + showcase.features({title: 'What a plan can include',lede: 'A creator builds each plan from perks; the plan lists exactly which, and each OpenVibe site that honours a perk recognises it.',items: [@@@ -73,10 +118,20 @@ function home({ creators }) {}) + showcase.steps({title: 'How a membership works',items: [-- { title: "Pick a creator's plan", text: 'Each creator page lists their plans, what each includes and its price.' },++ { title: "Pick a creator's plan", text: 'Each creator page lists their plan, what it includes and its price.' },{ title: 'Join through OpenVibe.Billing', text: 'Pay from your Vibes credit or another way Billing offers; renew automatically or not, your choice.' },{ title: 'Keep the terms you joined under', text: 'Plans are versioned: the version you joined stays yours while your membership lasts, and every version stays public in the terms history.' },],++ }) + showcase.steps({++ id: 'for-creators',++ title: 'Offer a membership',++ lede: 'Streaming on OpenVibe.Live already? Your channel subscription is your plan here, ready to describe and price.',++ items: [++ { title: 'Open your dashboard', href: '/dashboard', text: 'Sign in with your OpenVibe account; your plan and its members are all on one page.' },++ { title: 'Choose a tier and a price', text: 'Tiers run from 1 to 10. Set each tier\'s monthly price within Billing\'s limits; tier 1 sells at Billing\'s standard price until you set your own.' },++ { title: 'Say what members get', text: 'Write what the plan includes and pick its perks. Each change becomes a new version, and members keep the one they joined.' },++ { title: 'Get paid through Billing', text: 'Billing takes the payments and handles renewals and refunds; your earnings follow its payouts.' },++ ],}) + showcase.code({id: 'software',title: 'Sell a subscription to your software',@@@ -102,7 +157,7 @@ if (e.active) unlockPro(); // expired, unknown or unreachable all answer no`,},],}) + `--<section class="sc-sec" id="creators" aria-labelledby="creators-h"><h2 id="creators-h">Creators with plans</h2>${list}<p class="muted creators-cta">Are you a creator? <a href="/dashboard">Offer a plan</a>.</p></section>`;++<section class="sc-sec" id="questions" aria-labelledby="questions-h"><h2 id="questions-h">Questions</h2><div class="vip-faq">${HOME_FAQ.map((f) => `<details><summary>${esc(f.q)}</summary><p>${esc(f.a)}</p></details>`).join('')}</div></section>`;}/** The creator's public plan page. */@@@ -295,4 +350,7 @@ ${rules.length ? `<ul class="rules">${rules.map((r) => `<li><code>${esc(r.resour<button type="submit">Save rule</button></form></details></section>`}`;}--module.exports = { errorPage, home, creatorPage, planHistory, memberPage, checkoutPage, dashboard, fmtDate };++module.exports = {++ HOME_TITLE, HOME_DESCRIPTION, HOME_FAQ, homeFaqJsonLd,++ errorPage, home, creatorPage, planHistory, memberPage, checkoutPage, dashboard, fmtDate,++};@@@ -36,7 +36,7 @@ const LLMS_DETAILS = 'A creator page lists the plans and perks a creator offers// most once an hour (the browser keeps it the same 3600 s).const LLMS_FULL_TTL_MS = 3600 * 1000;--function createWebRoutes({ domain, config, layout, userAuth }) {++function createWebRoutes({ domain, config, layout, userAuth, liveNow }) {const router = express.Router();const { creators, plans, perks, memberships, entitlements, policies, checkout, billing, prices } = domain;const forms = createForms({ secret: config.formSecret, now: domain.now });@@@ -179,10 +179,15 @@ function createWebRoutes({ domain, config, layout, userAuth }) {// What shipped on OpenVibe.VIP: the shared update log every OpenVibe site has.router.get('/updates', (req, res) => send(res, 200, { viewer: req.viewer, canonicalPath: '/updates', title: 'What shipped on OpenVibe.VIP', body: frame.updatesBody({ service: 'vip', siteName: 'OpenVibe.VIP' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>` }));-- router.get('/', async (req, res) => send(res, 200, {-- active: 'home', viewer: req.viewer, canonicalPath: '/', styles: [require('openvibe-shared/showcase').STYLESHEET],-- body: pages.home({ creators: (await creators.listPublic()).map(creators.present) }),-- }));++ router.get('/', async (req, res) => {++ const list = (await creators.listPublic()).map(creators.present);++ const live = liveNow ? await liveNow.list() : [];++ send(res, 200, {++ title: pages.HOME_TITLE, description: pages.HOME_DESCRIPTION, jsonLd: [pages.homeFaqJsonLd()],++ active: 'home', viewer: req.viewer, canonicalPath: '/', styles: [require('openvibe-shared/showcase').STYLESHEET],++ body: pages.home({ creators: list, live, networkUrl: config.network.url }),++ });++ });// ── The member's page ────────────────────────────────────router.get('/me', async (req, res, next) => {@@@ -206,7 +206,9 @@ const { test, run } = harness('api');assert.match(p.text, /id="ov-footer"/);assert.match(p.text, /OpenVibeFooter\.init\(window\.__OV_PAGE\.footer\)/);const home = await t.page('/');-- assert.match(home.text, /<title>OpenVibe\.VIP — memberships across OpenVibe<\/title>/);++ assert.match(home.text, /<title>Creator memberships across OpenVibe · OpenVibe\.VIP<\/title>/);++ assert.match(home.text, /"@type":"FAQPage"/, 'the questions shown are FAQPage data too');++ assert.ok(home.text.includes('id="questions"') && home.text.includes('id="for-creators"'), 'questions and the creators\' path');// The home opens with the showcase kit (openvibe-shared/showcase): one h1 (the hero), the kit's sheet on the// home only, no invented prices, and the creators still listed below it.assert.ok(home.text.includes('class="sc-hero') && home.text.includes('class="sc-sec"'), 'hero and sections');nnew file mode 100644@@@ -0,0 +1,63 @@++'use strict';++// The home's "Live now" strip and creator grid: creators with a plan who are live on OpenVibe.Live come first and get++// their own card (title, viewers, Watch and plan links); a channel live without a plan here is not shown; with nobody++// live the strip is absent. The FAQ on the page and its FAQPage data say the same thing. live-now keeps every live,++// non-NSFW channel from Live's public list and never breaks the page when Live fails.++const assert = require('assert');++const pages = require('../server/web/pages');++const { esc } = require('../server/util');++const { createLiveNow } = require('../server/live-now');++++const creators = [++ { username: 'alma', display_name: 'Alma' },++ { username: 'bo', display_name: 'Bo' },++ { username: 'Cyd', display_name: 'Cyd <3' },++];++const live = [++ { username: 'cyd', name: 'Cyd', title: 'Building a bot <live>', category: 'Science', viewers: 12, url: 'https://openvibe.live/@cyd' },++ { username: 'stranger', name: 'Stranger', title: 'No plan here', category: null, viewers: 40, url: 'https://openvibe.live/@stranger' },++];++++const html = pages.home({ creators, live, networkUrl: 'https://openvibe.network' });++assert.match(html, /id="live"/, 'the strip shows when a creator with a plan is live');++assert.match(html, /Building a bot <live>/, 'the stream title, escaped');++assert.match(html, /12 viewers/);++assert.match(html, /href="https:\/\/openvibe\.live\/@cyd" rel="noopener">Watch/);++assert.ok(!html.includes('No plan here'), 'a channel without a plan here is not on the home');++const grid = html.slice(html.indexOf('id="creators"'));++assert.ok(grid.indexOf('href="/Cyd"') < grid.indexOf('href="/alma"'), 'live creators come first in the grid');++assert.match(html, /src="https:\/\/openvibe\.network\/avatar\/alma\?s=40" width="40" height="40" alt="" loading="lazy"/, 'avatars from Network, sized');++assert.match(html, /<dt>3 creators<\/dt><dd>offer a membership<\/dd>/);++assert.match(html, /<dt>1 creator<\/dt><dd>live on OpenVibe\.Live now<\/dd>/);++++const quiet = pages.home({ creators, live: [] });++assert.ok(!quiet.includes('id="live"'), 'no strip when nobody with a plan is live');++assert.ok(!/live on OpenVibe\.Live now/.test(quiet), 'and no live count');++++const ld = pages.homeFaqJsonLd();++assert.strictEqual(ld['@type'], 'FAQPage');++assert.deepStrictEqual(ld.mainEntity.map((q) => q.name), pages.HOME_FAQ.map((f) => f.q), 'FAQPage data asks what the page asks');++const shown = [...html.matchAll(/<summary>([^<]*)<\/summary>/g)].map((m) => m[1]);++assert.deepStrictEqual(shown, pages.HOME_FAQ.map((f) => esc(f.q)), 'every question is on the page, in order');++assert.ok(pages.HOME_TITLE.length + ' · OpenVibe.VIP'.length <= 60, 'the title fits 60 characters');++assert.ok(pages.HOME_DESCRIPTION.length <= 160, 'the description fits 160 characters');++++(async () => {++ const config = { liveNow: true, liveUrl: 'https://openvibe.live' };++ const rows = [++ { username: 'a', is_live: 1, viewer_count: 3, title: 'x' },++ { username: 'b', is_live: 1, viewer_count: 9, is_nsfw: 1 },++ { username: 'c', is_live: 0, viewer_count: 50 },++ { username: 'bad name', is_live: 1, viewer_count: 99 },++ ];++ let calls = 0;++ const ok = createLiveNow({ config, log: { warn() {} }, fetchImpl: async () => { calls++; return { ok: true, json: async () => ({ streams: rows }) }; } });++ assert.deepStrictEqual((await ok.list()).map((s) => s.username), ['a'], 'live, safe-for-work, well-formed channels only');++ await ok.list();++ assert.strictEqual(calls, 1, 'read at most once a minute');++ const down = createLiveNow({ config, log: { warn() {} }, fetchImpl: async () => { throw new Error('refused'); } });++ assert.deepStrictEqual(await down.list(), [], 'Live down: an empty strip, never an error');++ const off = createLiveNow({ config: { ...config, liveNow: false }, fetchImpl: async () => { throw new Error('must not be called'); } });++ assert.deepStrictEqual(await off.list(), []);++ console.log('home live: all checks passed');++})().catch((err) => { console.error(err); process.exit(1); });
@@ -51,8 +51,11 @@ details > summary { cursor: pointer; font-weight: 600; margin: 6px 0; }
@@ -51,8 +51,11 @@ details > summary { cursor: pointer; font-weight: 600; margin: 6px 0; }
.pill { display: inline-block; border: 1px solid var(--vip-border); border-radius: 999px; padding: 0 10px; font-size: .85rem; color: var(--vip-muted); }
.pill { display: inline-block; border: 1px solid var(--vip-border); border-radius: 999px; padding: 0 10px; font-size: .85rem; color: var(--vip-muted); }
.pill-on { border-color: var(--vip-good); color: var(--vip-good); }
.pill-on { border-color: var(--vip-good); color: var(--vip-good); }
.price b { font-size: 1.15rem; }
.price b { font-size: 1.15rem; }
-.creator-list { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; }
-.creator-list a { display: flex; flex-direction: column; background: var(--vip-card); border: 1px solid var(--vip-border); border-radius: 10px; padding: 12px 14px; color: inherit; text-decoration: none; }
+.creator-list { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(min(220px, 100%), 1fr)); gap: 12px; }
+.creator-list a { display: flex; align-items: center; gap: 12px; height: 100%; background: var(--vip-card); border: 1px solid var(--vip-border); border-radius: 10px; padding: 10px 14px; color: inherit; text-decoration: none; transition: border-color .15s; }
+.creator-list a:hover, .creator-list a:focus-visible { border-color: var(--vip-accent); }
+.vip-creator-text { display: flex; flex-direction: column; min-width: 0; flex: 1; }
+.vip-creator-text b, .vip-creator-text span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.perks, .benefits, .versions, .rules, .perk-admin { padding-left: 1.2em; }
.perks, .benefits, .versions, .rules, .perk-admin { padding-left: 1.2em; }
.perk-admin { list-style: none; padding: 0; }
.perk-admin { list-style: none; padding: 0; }
table.members { width: 100%; border-collapse: collapse; display: block; overflow-x: auto; }
table.members { width: 100%; border-collapse: collapse; display: block; overflow-x: auto; }
@@ -66,3 +69,22 @@ code { background: rgba(255, 255, 255, .06); padding: 1px 5px; border-radius: 4p
@@ -66,3 +69,22 @@ code { background: rgba(255, 255, 255, .06); padding: 1px 5px; border-radius: 4p
@media (min-width: 640px) { .page .sc-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (min-width: 640px) { .page .sc-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
.page #creators .creator-list { margin-top: 12px; }
.page #creators .creator-list { margin-top: 12px; }
.page .creators-cta { margin-top: 14px; }
.page .creators-cta { margin-top: 14px; }
+
+/* The home: the numbers beside the hero, creators live now, avatars, questions. */
+.vip-avatar { flex: none; border-radius: 50%; background: var(--vip-border); object-fit: cover; }
+.vip-stats { margin: 0; display: grid; gap: 10px; }
+.vip-stats div { background: var(--vip-card); border: 1px solid var(--vip-border); border-radius: 12px; padding: 12px 16px; }
+.vip-stats dt { font-size: 1.25rem; font-weight: 700; }
+.vip-stats dd { margin: 2px 0 0; color: var(--vip-muted); font-size: .92rem; }
+.vip-live-pill { flex: none; display: inline-block; border-radius: 999px; padding: 0 8px; font-size: .75rem; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; background: var(--vip-bad); color: #1a0505; }
+.vip-live { list-style: none; padding: 0; margin: 12px 0 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(min(260px, 100%), 1fr)); gap: 12px; }
+.vip-live li { display: flex; gap: 12px; align-items: flex-start; background: var(--vip-card); border: 1px solid var(--vip-border); border-radius: 12px; padding: 12px 14px; }
+.vip-live-main { min-width: 0; flex: 1; }
+.vip-live-main p { margin: 0 0 4px; }
+.vip-live-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.vip-live-links { display: flex; flex-wrap: wrap; gap: 6px 14px; margin-top: 6px !important; font-weight: 600; }
+.vip-faq { margin-top: 12px; display: grid; gap: 8px; }
+.vip-faq details { background: var(--vip-card); border: 1px solid var(--vip-border); border-radius: 10px; padding: 4px 16px; }
+.vip-faq details[open] { padding-bottom: 12px; }
+.vip-faq summary { padding: 8px 0; margin: 0; }
+.vip-faq p { margin: 4px 0 0; color: var(--vip-muted); }
@@ -13,7 +13,7 @@
@@ -13,7 +13,7 @@
* /embed/:username/card.json|badge.svg|widget public card, badge and widget (no cookies, see web/embeds.js)
* /embed/:username/card.json|badge.svg|widget public card, badge and widget (no cookies, see web/embeds.js)
* /, /me, /dashboard, /:username … server-rendered pages
* /, /me, /dashboard, /:username … server-rendered pages
*
*
- * createApp({ config, db, keys, billing, outbox, now, fetchImpl, eventsFetch, log }) — all injectable.
+ * createApp({ config, db, keys, billing, outbox, now, fetchImpl, eventsFetch, liveNow, log }) — all injectable.
*/
*/
const path = require('path');
const path = require('path');
const express = require('express');
const express = require('express');
@@ -135,7 +135,8 @@ async function createApp(opts = {}) {
@@ -135,7 +135,8 @@ async function createApp(opts = {}) {
res.setHeader('Cache-Control', cache.assetHeaders(rel, { hashed: v && v === assetVersion(rel) }));
res.setHeader('Cache-Control', cache.assetHeaders(rel, { hashed: v && v === assetVersion(rel) }));
},
},
}));
}));
- app.use(createWebRoutes({ domain, config, layout, userAuth }));
+ const liveNow = opts.liveNow || require('./live-now').createLiveNow({ config, fetchImpl, log });
+ app.use(createWebRoutes({ domain, config, layout, userAuth, liveNow }));
app.use((req, res) => {
app.use((req, res) => {
if (req.path.startsWith('/api/') || req.path.startsWith('/internal/')) return http.sendProblem(res, 404, 'not_found', { ctx: req.ov });
if (req.path.startsWith('/api/') || req.path.startsWith('/internal/')) return http.sendProblem(res, 404, 'not_found', { ctx: req.ov });
@@ -89,6 +89,8 @@ function loadConfig(env = process.env) {
@@ -89,6 +89,8 @@ function loadConfig(env = process.env) {
refreshIntervalMs: int(env.VIP_REFRESH_INTERVAL_MS, 60 * 1000),
refreshIntervalMs: int(env.VIP_REFRESH_INTERVAL_MS, 60 * 1000),
},
},
liveUrl: trim(env.LIVE_URL || 'https://openvibe.live'),
liveUrl: trim(env.LIVE_URL || 'https://openvibe.live'),
+ // The home's "Live now" strip reads Live's public stream list (server/live-now.js); off in tests unless asked.
+ liveNow: env.VIP_LIVE_NOW ? env.VIP_LIVE_NOW === 'on' : nodeEnv !== 'test',
// The public card, badge and widget (/embed, server/web/embeds.js). frameAncestors is the
// The public card, badge and widget (/embed, server/web/embeds.js). frameAncestors is the
// widget's CSP frame-ancestors (any site by default: it is meant to be embedded; the rest of
// widget's CSP frame-ancestors (any site by default: it is meant to be embedded; the rest of
new file mode 100644
new file mode 100644
@@ -0,0 +1,62 @@
@@ -0,0 +1,62 @@
+'use strict';
+
+/**
+ * Who is live on OpenVibe.Live right now, for the home's "Live now" strip (creators with a plan who are live): Live's
+ * public stream list (GET <LIVE_URL>/api/streams, the list its own home shows), read at most once a minute and kept in
+ * memory. Only what a card needs is kept: the channel's name, title, category, viewers and start time; never Live's AI
+ * text.
+ *
+ * A slow or failing Live never slows or breaks the page: the call times out after 2 seconds and the last good list (or
+ * none) is used. Off in tests unless VIP_LIVE_NOW=on (config.liveNow). The same client as OpenVibe.Tips'
+ * server/clients/live-now.js, keeping every live channel (the home keeps the ones with a plan).
+ */
+const TTL_MS = 60_000;
+const TIMEOUT_MS = 2_000;
+const MAX = 100;
+
+const str = (v, max) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, max) : null);
+
+function createLiveNow({ config, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console } = {}) {
+ let cache = { at: 0, list: [] };
+ let inflight = null;
+
+ function shape(rows) {
+ return (Array.isArray(rows) ? rows : []).filter((s) => s && (s.is_live === 1 || s.is_live === true) && !s.is_nsfw && /^[A-Za-z0-9_-]{1,40}$/.test(String(s.username || '')))
+ .sort((a, b) => (Number(b.viewer_count) || 0) - (Number(a.viewer_count) || 0))
+ .slice(0, MAX)
+ .map((s) => ({
+ username: String(s.username), name: str(s.display_name, 60) || String(s.username), title: str(s.title, 120),
+ category: str(s.chosen_category || s.category, 40), viewers: Math.max(0, Number(s.viewer_count) || 0),
+ started_at: str(s.started_at, 40), url: `${config.liveUrl}/@${encodeURIComponent(String(s.username))}`,
+ }));
+ }
+
+ async function refresh() {
+ const ctrl = new AbortController();
+ const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
+ try {
+ const res = await fetchImpl(`${config.liveUrl}/api/streams`, { headers: { Accept: 'application/json' }, signal: ctrl.signal });
+ if (!res.ok) throw new Error(`Live answered ${res.status}`);
+ const body = await res.json();
+ cache = { at: now(), list: shape(body && body.streams) };
+ } catch (err) {
+ cache = { at: now(), list: cache.list }; // keep the last good list; try again after the TTL
+ log.warn(`[VIP] live-now: ${err.message}`);
+ } finally {
+ clearTimeout(timer);
+ }
+ return cache.list;
+ }
+
+ return {
+ /** The channels live now, the most watched first (at most 100); [] when Live is off or unreachable. */
+ async list() {
+ if (!config.liveNow || !config.liveUrl) return [];
+ if (now() - cache.at < TTL_MS) return cache.list;
+ inflight = inflight || refresh().finally(() => { inflight = null; });
+ return await inflight;
+ },
+ };
+}
+
+module.exports = { createLiveNow, TTL_MS };
@@ -45,23 +45,68 @@ function errorPage({ status, title, message }) {
@@ -45,23 +45,68 @@ function errorPage({ status, title, message }) {
return `<section class="card narrow"><p class="eyebrow">${esc(status)}</p><h1>${esc(title)}</h1><p>${esc(message)}</p><p><a href="/">OpenVibe.VIP home</a></p></section>`;
return `<section class="card narrow"><p class="eyebrow">${esc(status)}</p><h1>${esc(title)}</h1><p>${esc(message)}</p><p><a href="/">OpenVibe.VIP home</a></p></section>`;
}
}
+/** The home's title and description (layout adds " · OpenVibe.VIP"). */
+const HOME_TITLE = 'Creator memberships across OpenVibe';
+const HOME_DESCRIPTION = "Back the creators you watch with a monthly membership, recognised on every OpenVibe site. Or sell one for your channel or your software.";
+
+/** The questions the home answers, shown on the page and given to search engines as FAQPage data (the same text). */
+const HOME_FAQ = [
+ { q: 'What is a membership?', a: "A monthly plan from one creator. The plan lists what it includes, such as a members badge by your name in chat, members-only posts and a members' room, and each OpenVibe site that honours a perk recognises it." },
+ { q: 'How do I pay, and can I stop?', a: 'OpenVibe.Billing charges and renews every membership. Pay from your Vibes credit or another way Billing offers, and turn renewal off whenever you like: you keep the membership until the end of the period you paid for.' },
+ { q: 'What if the creator changes the plan?', a: 'Plans are versioned. The version you joined stays yours while your membership lasts, and every version stays public in the plan\'s terms history.' },
+ { q: 'Is a channel subscription on OpenVibe.Live the same thing?', a: "Yes. A creator's channel subscription on OpenVibe.Live is their plan here: the same Billing subscription, with its terms and perks written down." },
+ { q: 'How do I offer a plan?', a: 'Sign in and open your dashboard. Choose a tier from 1 to 10 and its monthly price, pick the perks, and publish. Billing takes the payments and your earnings follow Billing\'s payouts.' },
+ { q: 'Can I sell a subscription to my own app?', a: 'Yes. Publish a plan, then have your app ask VIP whether the person signed in with OpenVibe is a member, with the SDK or one HTTPS call. The answer is no as soon as a membership ends.' },
+];
+
+/** FAQPage structured data for the home, from the same questions the page shows. */
+function homeFaqJsonLd() {
+ return {
+ '@context': 'https://schema.org', '@type': 'FAQPage',
+ mainEntity: HOME_FAQ.map((f) => ({ '@type': 'Question', name: f.q, acceptedAnswer: { '@type': 'Answer', text: f.a } })),
+ };
+}
+
+const avatar = (networkUrl, username, size) => `<img class="vip-avatar" src="${esc(networkUrl)}/avatar/${encodeURIComponent(username)}?s=${size}" width="${size}" height="${size}" alt="" loading="lazy" decoding="async">`;
+const plural = (n, one, many) => `${n.toLocaleString('en-US')} ${n === 1 ? one : many}`;
+
/**
/**
- * The home: the product's own words (openvibe-shared/showcase), then the creators with published plans. No prices here:
- * OpenVibe.Billing prices each plan, and a creator's page shows it before anyone joins.
+ * The home: who it is for in one line, the real numbers beside it, creators with a plan who are live on OpenVibe.Live
+ * right now, every creator with a plan (live ones first), what a plan can include, how a membership works, how a creator
+ * offers one, selling a subscription to software, and the questions people ask. No prices here: OpenVibe.Billing prices
+ * each plan, and a creator's page shows it before anyone joins. live: server/live-now.js (Live's public stream list).
*/
*/
-function home({ creators }) {
- const list = creators.length
- ? `<ul class="creator-list">${creators.map((c) => `<li><a href="/${encodeURIComponent(c.username)}"><b>${esc(c.display_name || c.username)}</b><span class="muted">@${esc(c.username)}</span></a></li>`).join('')}</ul>`
+function home({ creators, live = [], networkUrl = 'https://openvibe.network' }) {
+ const onAir = new Map(live.map((s) => [String(s.username).toLowerCase(), s]));
+ const liveCreators = creators.filter((c) => c.username && onAir.has(c.username.toLowerCase())).map((c) => ({ c, s: onAir.get(c.username.toLowerCase()) }));
+ const ordered = [...liveCreators.map((x) => x.c), ...creators.filter((c) => !c.username || !onAir.has(c.username.toLowerCase()))];
+
+ const stats = [];
+ if (creators.length) stats.push(`<div><dt>${plural(creators.length, 'creator', 'creators')}</dt><dd>offer a membership</dd></div>`);
+ if (liveCreators.length) stats.push(`<div><dt>${plural(liveCreators.length, 'creator', 'creators')}</dt><dd>live on OpenVibe.Live now</dd></div>`);
+ stats.push('<div><dt>10 tiers</dt><dd>each creator prices their own</dd></div>');
+ stats.push('<div><dt>1 membership</dt><dd>recognised on every OpenVibe site that honours it</dd></div>');
+
+ const liveSection = liveCreators.length ? `
+<section class="sc-sec" id="live" aria-labelledby="live-h"><h2 id="live-h">Live now</h2><p class="sc-lede">Creators with a membership who are streaming on OpenVibe.Live right now.</p>
+<ul class="vip-live">${liveCreators.map(({ c, s }) => `<li>${avatar(networkUrl, c.username, 48)}<div class="vip-live-main"><p class="vip-live-name"><b>${esc(c.display_name || c.username)}</b> <span class="vip-live-pill">Live</span></p>${s.title ? `<p class="vip-live-title">${esc(s.title)}</p>` : ''}<p class="muted small">${s.category ? `${esc(s.category)} · ` : ''}${plural(s.viewers, 'viewer', 'viewers')}</p><p class="vip-live-links"><a href="${esc(s.url)}" rel="noopener">Watch</a><a href="/${encodeURIComponent(c.username)}">See the plan</a></p></div></li>`).join('')}</ul></section>` : '';
+
+ const list = ordered.length
+ ? `<ul class="creator-list">${ordered.map((c) => `<li><a href="/${encodeURIComponent(c.username)}">${avatar(networkUrl, c.username, 40)}<span class="vip-creator-text"><b>${esc(c.display_name || c.username)}</b><span class="muted">@${esc(c.username)}</span></span>${onAir.has(c.username.toLowerCase()) ? '<span class="vip-live-pill">Live</span>' : ''}</a></li>`).join('')}</ul>`
: '<p class="muted">No creator has published a plan here yet.</p>';
: '<p class="muted">No creator has published a plan here yet.</p>';
+
return showcase.hero({
return showcase.hero({
eyebrow: 'OpenVibe.VIP',
eyebrow: 'OpenVibe.VIP',
title: 'Memberships across', accent: 'OpenVibe',
title: 'Memberships across', accent: 'OpenVibe',
- lede: "Join a creator's plan once and your membership is recognised on every OpenVibe site that honours it: a badge in chat, members-only posts, rooms and more, as each plan lists.",
+ lede: "Back the creators you watch with a monthly membership. Join once and it's recognised across OpenVibe: a badge by your name in chat, members-only posts, a members' room, whatever the plan lists.",
actions: creators.length
actions: creators.length
- ? [{ label: 'Find a creator', href: '#creators', primary: true }, { label: 'Your memberships', href: '/me' }]
+ ? [{ label: 'Find a creator', href: '#creators', primary: true }, { label: 'Offer a plan', href: '#for-creators' }, { label: 'Your memberships', href: '/me' }]
: [{ label: 'Offer a plan', href: '/dashboard', primary: true }, { label: 'Your memberships', href: '/me' }],
: [{ label: 'Offer a plan', href: '/dashboard', primary: true }, { label: 'Your memberships', href: '/me' }],
note: 'Payments, renewals, cancellations and refunds are handled by OpenVibe.Billing.',
note: 'Payments, renewals, cancellations and refunds are handled by OpenVibe.Billing.',
- }) + showcase.features({
+ html: `<dl class="vip-stats">${stats.join('')}</dl>`,
+ }) + liveSection + `
+<section class="sc-sec" id="creators" aria-labelledby="creators-h"><h2 id="creators-h">Creators with a membership</h2><p class="sc-lede">Each page lists the creator's plan, what it includes and its price before you join.</p>${list}<p class="muted creators-cta">Are you a creator? <a href="/dashboard">Offer a plan</a>.</p></section>`
+ + showcase.features({
title: 'What a plan can include',
title: 'What a plan can include',
lede: 'A creator builds each plan from perks; the plan lists exactly which, and each OpenVibe site that honours a perk recognises it.',
lede: 'A creator builds each plan from perks; the plan lists exactly which, and each OpenVibe site that honours a perk recognises it.',
items: [
items: [
@@ -73,10 +118,20 @@ function home({ creators }) {
@@ -73,10 +118,20 @@ function home({ creators }) {
}) + showcase.steps({
}) + showcase.steps({
title: 'How a membership works',
title: 'How a membership works',
items: [
items: [
- { title: "Pick a creator's plan", text: 'Each creator page lists their plans, what each includes and its price.' },
+ { title: "Pick a creator's plan", text: 'Each creator page lists their plan, what it includes and its price.' },
{ title: 'Join through OpenVibe.Billing', text: 'Pay from your Vibes credit or another way Billing offers; renew automatically or not, your choice.' },
{ title: 'Join through OpenVibe.Billing', text: 'Pay from your Vibes credit or another way Billing offers; renew automatically or not, your choice.' },
{ title: 'Keep the terms you joined under', text: 'Plans are versioned: the version you joined stays yours while your membership lasts, and every version stays public in the terms history.' },
{ title: 'Keep the terms you joined under', text: 'Plans are versioned: the version you joined stays yours while your membership lasts, and every version stays public in the terms history.' },
],
],
+ }) + showcase.steps({
+ id: 'for-creators',
+ title: 'Offer a membership',
+ lede: 'Streaming on OpenVibe.Live already? Your channel subscription is your plan here, ready to describe and price.',
+ items: [
+ { title: 'Open your dashboard', href: '/dashboard', text: 'Sign in with your OpenVibe account; your plan and its members are all on one page.' },
+ { title: 'Choose a tier and a price', text: 'Tiers run from 1 to 10. Set each tier\'s monthly price within Billing\'s limits; tier 1 sells at Billing\'s standard price until you set your own.' },
+ { title: 'Say what members get', text: 'Write what the plan includes and pick its perks. Each change becomes a new version, and members keep the one they joined.' },
+ { title: 'Get paid through Billing', text: 'Billing takes the payments and handles renewals and refunds; your earnings follow its payouts.' },
+ ],
}) + showcase.code({
}) + showcase.code({
id: 'software',
id: 'software',
title: 'Sell a subscription to your software',
title: 'Sell a subscription to your software',
@@ -102,7 +157,7 @@ if (e.active) unlockPro(); // expired, unknown or unreachable all answer no`,
@@ -102,7 +157,7 @@ if (e.active) unlockPro(); // expired, unknown or unreachable all answer no`,
},
},
],
],
}) + `
}) + `
-<section class="sc-sec" id="creators" aria-labelledby="creators-h"><h2 id="creators-h">Creators with plans</h2>${list}<p class="muted creators-cta">Are you a creator? <a href="/dashboard">Offer a plan</a>.</p></section>`;
+<section class="sc-sec" id="questions" aria-labelledby="questions-h"><h2 id="questions-h">Questions</h2><div class="vip-faq">${HOME_FAQ.map((f) => `<details><summary>${esc(f.q)}</summary><p>${esc(f.a)}</p></details>`).join('')}</div></section>`;
}
}
/** The creator's public plan page. */
/** The creator's public plan page. */
@@ -295,4 +350,7 @@ ${rules.length ? `<ul class="rules">${rules.map((r) => `<li><code>${esc(r.resour
@@ -295,4 +350,7 @@ ${rules.length ? `<ul class="rules">${rules.map((r) => `<li><code>${esc(r.resour
<button type="submit">Save rule</button></form></details></section>`}`;
<button type="submit">Save rule</button></form></details></section>`}`;
}
}
-module.exports = { errorPage, home, creatorPage, planHistory, memberPage, checkoutPage, dashboard, fmtDate };
+module.exports = {
+ HOME_TITLE, HOME_DESCRIPTION, HOME_FAQ, homeFaqJsonLd,
+ errorPage, home, creatorPage, planHistory, memberPage, checkoutPage, dashboard, fmtDate,
+};
@@ -36,7 +36,7 @@ const LLMS_DETAILS = 'A creator page lists the plans and perks a creator offers
@@ -36,7 +36,7 @@ const LLMS_DETAILS = 'A creator page lists the plans and perks a creator offers
// most once an hour (the browser keeps it the same 3600 s).
// most once an hour (the browser keeps it the same 3600 s).
const LLMS_FULL_TTL_MS = 3600 * 1000;
const LLMS_FULL_TTL_MS = 3600 * 1000;
-function createWebRoutes({ domain, config, layout, userAuth }) {
+function createWebRoutes({ domain, config, layout, userAuth, liveNow }) {
const router = express.Router();
const router = express.Router();
const { creators, plans, perks, memberships, entitlements, policies, checkout, billing, prices } = domain;
const { creators, plans, perks, memberships, entitlements, policies, checkout, billing, prices } = domain;
const forms = createForms({ secret: config.formSecret, now: domain.now });
const forms = createForms({ secret: config.formSecret, now: domain.now });
@@ -179,10 +179,15 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
@@ -179,10 +179,15 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
// What shipped on OpenVibe.VIP: the shared update log every OpenVibe site has.
// What shipped on OpenVibe.VIP: the shared update log every OpenVibe site has.
router.get('/updates', (req, res) => send(res, 200, { viewer: req.viewer, canonicalPath: '/updates', title: 'What shipped on OpenVibe.VIP', body: frame.updatesBody({ service: 'vip', siteName: 'OpenVibe.VIP' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>` }));
router.get('/updates', (req, res) => send(res, 200, { viewer: req.viewer, canonicalPath: '/updates', title: 'What shipped on OpenVibe.VIP', body: frame.updatesBody({ service: 'vip', siteName: 'OpenVibe.VIP' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>` }));
- router.get('/', async (req, res) => send(res, 200, {
- active: 'home', viewer: req.viewer, canonicalPath: '/', styles: [require('openvibe-shared/showcase').STYLESHEET],
- body: pages.home({ creators: (await creators.listPublic()).map(creators.present) }),
- }));
+ router.get('/', async (req, res) => {
+ const list = (await creators.listPublic()).map(creators.present);
+ const live = liveNow ? await liveNow.list() : [];
+ send(res, 200, {
+ title: pages.HOME_TITLE, description: pages.HOME_DESCRIPTION, jsonLd: [pages.homeFaqJsonLd()],
+ active: 'home', viewer: req.viewer, canonicalPath: '/', styles: [require('openvibe-shared/showcase').STYLESHEET],
+ body: pages.home({ creators: list, live, networkUrl: config.network.url }),
+ });
+ });
// ── The member's page ────────────────────────────────────
// ── The member's page ────────────────────────────────────
router.get('/me', async (req, res, next) => {
router.get('/me', async (req, res, next) => {
@@ -206,7 +206,9 @@ const { test, run } = harness('api');
@@ -206,7 +206,9 @@ const { test, run } = harness('api');
assert.match(p.text, /id="ov-footer"/);
assert.match(p.text, /id="ov-footer"/);
assert.match(p.text, /OpenVibeFooter\.init\(window\.__OV_PAGE\.footer\)/);
assert.match(p.text, /OpenVibeFooter\.init\(window\.__OV_PAGE\.footer\)/);
const home = await t.page('/');
const home = await t.page('/');
- assert.match(home.text, /<title>OpenVibe\.VIP — memberships across OpenVibe<\/title>/);
+ assert.match(home.text, /<title>Creator memberships across OpenVibe · OpenVibe\.VIP<\/title>/);
+ assert.match(home.text, /"@type":"FAQPage"/, 'the questions shown are FAQPage data too');
+ assert.ok(home.text.includes('id="questions"') && home.text.includes('id="for-creators"'), 'questions and the creators\' path');
// The home opens with the showcase kit (openvibe-shared/showcase): one h1 (the hero), the kit's sheet on the
// The home opens with the showcase kit (openvibe-shared/showcase): one h1 (the hero), the kit's sheet on the
// home only, no invented prices, and the creators still listed below it.
// home only, no invented prices, and the creators still listed below it.
assert.ok(home.text.includes('class="sc-hero') && home.text.includes('class="sc-sec"'), 'hero and sections');
assert.ok(home.text.includes('class="sc-hero') && home.text.includes('class="sc-sec"'), 'hero and sections');
new file mode 100644
new file mode 100644
@@ -0,0 +1,63 @@
@@ -0,0 +1,63 @@
+'use strict';
+// The home's "Live now" strip and creator grid: creators with a plan who are live on OpenVibe.Live come first and get
+// their own card (title, viewers, Watch and plan links); a channel live without a plan here is not shown; with nobody
+// live the strip is absent. The FAQ on the page and its FAQPage data say the same thing. live-now keeps every live,
+// non-NSFW channel from Live's public list and never breaks the page when Live fails.
+const assert = require('assert');
+const pages = require('../server/web/pages');
+const { esc } = require('../server/util');
+const { createLiveNow } = require('../server/live-now');
+
+const creators = [
+ { username: 'alma', display_name: 'Alma' },
+ { username: 'bo', display_name: 'Bo' },
+ { username: 'Cyd', display_name: 'Cyd <3' },
+];
+const live = [
+ { username: 'cyd', name: 'Cyd', title: 'Building a bot <live>', category: 'Science', viewers: 12, url: 'https://openvibe.live/@cyd' },
+ { username: 'stranger', name: 'Stranger', title: 'No plan here', category: null, viewers: 40, url: 'https://openvibe.live/@stranger' },
+];
+
+const html = pages.home({ creators, live, networkUrl: 'https://openvibe.network' });
+assert.match(html, /id="live"/, 'the strip shows when a creator with a plan is live');
+assert.match(html, /Building a bot <live>/, 'the stream title, escaped');
+assert.match(html, /12 viewers/);
+assert.match(html, /href="https:\/\/openvibe\.live\/@cyd" rel="noopener">Watch/);
+assert.ok(!html.includes('No plan here'), 'a channel without a plan here is not on the home');
+const grid = html.slice(html.indexOf('id="creators"'));
+assert.ok(grid.indexOf('href="/Cyd"') < grid.indexOf('href="/alma"'), 'live creators come first in the grid');
+assert.match(html, /src="https:\/\/openvibe\.network\/avatar\/alma\?s=40" width="40" height="40" alt="" loading="lazy"/, 'avatars from Network, sized');
+assert.match(html, /<dt>3 creators<\/dt><dd>offer a membership<\/dd>/);
+assert.match(html, /<dt>1 creator<\/dt><dd>live on OpenVibe\.Live now<\/dd>/);
+
+const quiet = pages.home({ creators, live: [] });
+assert.ok(!quiet.includes('id="live"'), 'no strip when nobody with a plan is live');
+assert.ok(!/live on OpenVibe\.Live now/.test(quiet), 'and no live count');
+
+const ld = pages.homeFaqJsonLd();
+assert.strictEqual(ld['@type'], 'FAQPage');
+assert.deepStrictEqual(ld.mainEntity.map((q) => q.name), pages.HOME_FAQ.map((f) => f.q), 'FAQPage data asks what the page asks');
+const shown = [...html.matchAll(/<summary>([^<]*)<\/summary>/g)].map((m) => m[1]);
+assert.deepStrictEqual(shown, pages.HOME_FAQ.map((f) => esc(f.q)), 'every question is on the page, in order');
+assert.ok(pages.HOME_TITLE.length + ' · OpenVibe.VIP'.length <= 60, 'the title fits 60 characters');
+assert.ok(pages.HOME_DESCRIPTION.length <= 160, 'the description fits 160 characters');
+
+(async () => {
+ const config = { liveNow: true, liveUrl: 'https://openvibe.live' };
+ const rows = [
+ { username: 'a', is_live: 1, viewer_count: 3, title: 'x' },
+ { username: 'b', is_live: 1, viewer_count: 9, is_nsfw: 1 },
+ { username: 'c', is_live: 0, viewer_count: 50 },
+ { username: 'bad name', is_live: 1, viewer_count: 99 },
+ ];
+ let calls = 0;
+ const ok = createLiveNow({ config, log: { warn() {} }, fetchImpl: async () => { calls++; return { ok: true, json: async () => ({ streams: rows }) }; } });
+ assert.deepStrictEqual((await ok.list()).map((s) => s.username), ['a'], 'live, safe-for-work, well-formed channels only');
+ await ok.list();
+ assert.strictEqual(calls, 1, 'read at most once a minute');
+ const down = createLiveNow({ config, log: { warn() {} }, fetchImpl: async () => { throw new Error('refused'); } });
+ assert.deepStrictEqual(await down.list(), [], 'Live down: an empty strip, never an error');
+ const off = createLiveNow({ config: { ...config, liveNow: false }, fetchImpl: async () => { throw new Error('must not be called'); } });
+ assert.deepStrictEqual(await off.list(), []);
+ console.log('home live: all checks passed');
+})().catch((err) => { console.error(err); process.exit(1); });