openvibers/openvibe.tips Commits

The Tips front page shows who is live on OpenVibe, the creators with tip pages and their goals, what supporters can send and creators get, the services a creator can link, and answers to common questions (#74)

OP OpenVibers committed 22 days ago · 9bdad4bce0 · 10 files, +228 −26

Changes

ddiff --git a/README.md b/README.md
iindex ae82400..d996319 100644
---- a/README.md
++++ b/README.md
@@@ -48,7 +48,9 @@ from *interaction delivered*.
(openvibe-sdk transactional outbox)
- **OpenVibe.Network** — SSO for people, service tokens, JWKS, identity resolve (a creator's Chat room)
- **OpenVibe.Chat** — chat delivery through its typed ingress (`/internal/chat/messages`, `/internal/chat/events`)
--- **OpenVibe.Live** — overlay consumer
++- **OpenVibe.Live** — overlay consumer; its public stream list (`GET <LIVE_URL>/api/streams`) feeds the home's "Live on
++ OpenVibe now" ([server/clients/live-now.js](server/clients/live-now.js): once a minute, 2 s timeout, never Live's AI
++ text, nothing when Live does not answer; `TIPS_LIVE_NOW=on|off`, off in tests by default)
- **OpenVibe.Shared** v2.2.0 (app icon, SSR footer, noscript nav, release manifest, legal pages, boost page moves) and
the Network's `navbar.js`; **openvibe-contracts** v0.79.0 and **openvibe-sdk** v0.28.0 (service tokens,
the async data layer `openvibe-sdk/db`, the PostgreSQL outbox and inbox — the events outbox is the SDK's
@@@ -341,7 +343,9 @@ capabilities above (`TIPS_CHAT_URL` only when Chat is not on `127.0.0.1:4400`).
## Pages (server-rendered, useful without JavaScript)
--`/` · `/<handle>` (goals + tip form posting to checkout; `index,follow` only when the creator switched the
++`/` (who is live on OpenVibe, the creators who switched their page on with their first active goal, what a supporter can
++send, what a creator gets, the services a creator can link, how it works, what it costs, six questions with their
++FAQPage JSON-LD; a section with nothing to show is left out) · `/<handle>` (goals + tip form posting to checkout; `index,follow` only when the creator switched the
page on, otherwise 404 for everyone else and a `noindex` preview for the creator) · `/<handle>/goals` ·
`/<handle>/supporters` (when the creator shows it) · `/receipts`, `/receipts/:id` (payment and delivery
shown separately, with the privacy chosen), `/receipts/export`, `/receipts/erase` · `/moderate/<handle>`
ddiff --git a/public/css/tips.css b/public/css/tips.css
iindex 0a82ba5..b04e771 100644
---- a/public/css/tips.css
++++ b/public/css/tips.css
@@@ -91,3 +91,26 @@ ul.mod-list, ul.plain { list-style: none; padding: 0; }
.page .sc-tiers { grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); }
@media (min-width: 640px) { .page .sc-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
.page .sc-price { font-size: 24px; }
++/* The front page (pages.home): 1080 px, live channels and creators as cards, service chips, questions. */
++.page.page-wide { max-width: 1080px; }
++.page.page-wide .sc-cta { padding-left: 0; padding-right: 0; }
++@media (min-width: 900px) { .page.page-wide .sc-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
++.tp-sec { margin-top: 44px; }
++.tp-head { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 4px 16px; }
++.tp-head h2 { margin: 0; font-size: clamp(1.3rem, 2.4vw, 1.75rem); letter-spacing: -.01em; }
++.tp-more { font-weight: 600; font-size: .95rem; text-decoration: none; }
++.tp-more:hover { text-decoration: underline; text-underline-offset: 3px; }
++.tp-lede { margin: 4px 0 14px; color: var(--tips-muted); }
++.tp-cards { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(min(240px, 100%), 1fr)); }
++.tp-card { display: flex; flex-direction: column; gap: 6px; min-width: 0; padding: 14px 16px; border-radius: 14px; border: 1px solid var(--tips-border); background: var(--tips-card); overflow-wrap: anywhere; }
++.tp-card p { margin: 0; }
++.tp-card-top { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
++.tp-card-top img { border-radius: 50%; object-fit: cover; }
++.tp-live { display: inline-block; padding: 1px 8px; border-radius: 999px; font-size: .72rem; font-weight: 800; letter-spacing: .04em; text-transform: uppercase; background: var(--danger, #dc2626); color: var(--on-accent, #fff); }
++.tp-chips { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; }
++.tp-chips li { padding: 6px 12px; border-radius: 999px; border: 1px solid var(--tips-border); background: var(--tips-card); font-size: .92rem; }
++.tp-faq { display: grid; gap: 8px; }
++.tp-faq details { border: 1px solid var(--tips-border); border-radius: 12px; background: var(--tips-card); padding: 0 16px; }
++.tp-faq summary { cursor: pointer; font-weight: 600; padding: 12px 0; }
++.tp-faq p { margin: 0 0 14px; color: var(--tips-muted); }
++@media (max-width: 600px) { .tp-cards > li:nth-child(n+5) { display: none; } }
ddiff --git a/server/app.js b/server/app.js
iindex 78a86aa..4a185aa 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -138,7 +138,8 @@ 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('./clients/live-now').createLiveNow({ config, fetchImpl: opts.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 });
ddiff --git a/server/clients/live-now.js b/server/clients/live-now.js
nnew file mode 100644
iindex 0000000..d7ccb81
---- /dev/null
++++ b/server/clients/live-now.js
@@@ -0,0 +1,60 @@
++'use strict';
++
++/**
++ * Who is live on OpenVibe.Live right now, for the front page's "Live now" section: 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 TIPS_LIVE_NOW=on (config.liveNow).
++ */
++const TTL_MS = 60_000;
++const TIMEOUT_MS = 2_000;
++const MAX = 8;
++
++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(`[Tips] live-now: ${err.message}`);
++ } finally {
++ clearTimeout(timer);
++ }
++ return cache.list;
++ }
++
++ return {
++ /** The channels live now, the most watched first (at most eight); [] 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 };
ddiff --git a/server/config.js b/server/config.js
iindex aec0704..83ef655 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -135,6 +135,8 @@ function loadConfig(env = process.env) {
intervalMs: int(env.TIPS_JOBS_INTERVAL_MS, 2000),
},
liveUrl: trim(env.LIVE_URL || 'https://openvibe.live'),
++ // The front page's "Live now" (clients/live-now.js): Live's public stream list. Off in tests unless asked for.
++ liveNow: env.TIPS_LIVE_NOW ? env.TIPS_LIVE_NOW === 'on' : nodeEnv !== 'test',
// Media requests Tips accepts (the media queue owner re-checks them).
mediaHosts: list(env.TIPS_MEDIA_HOSTS || 'youtube.com,www.youtube.com,m.youtube.com,music.youtube.com,youtu.be'),
};
ddiff --git a/server/web/layout.js b/server/web/layout.js
iindex 6c1df80..4dbc79f 100644
---- a/server/web/layout.js
++++ b/server/web/layout.js
@@@ -47,7 +47,7 @@ function createLayout({ config, release }) {
/**
* o: title, description, canonicalPath, robots ('index,follow' | 'noindex,nofollow'), body,
-- * active ('home'|'dashboard'|'receipts'|'creator'), ogImage, jsonLd (array), viewer
++ * active ('home'|'dashboard'|'receipts'|'creator'), ogImage, jsonLd (array), viewer, wide (the home: 1080 px)
*/
function page(o) {
const description = (o.description || DEFAULT_DESCRIPTION).replace(/\s+/g, ' ').trim().slice(0, 300);
@@@ -89,7 +89,7 @@ function createLayout({ config, release }) {
].filter(Boolean).join('\n'),
body: `<div id="navbar-mount"></div>
<header class="site-head"><a class="brand" href="/">${SITE_NAME}</a><nav>${NAV_LINKS.map((l) => `<a href="${l.href}"${o.active === l.label.toLowerCase() ? ' aria-current="page"' : ''}>${l.label}</a>`).join('')}</nav>${who}</header>
--<main id="main" class="page">
++<main id="main" class="page${o.wide ? ' page-wide' : ''}">
${o.body || ''}
${o.canonicalPath === '/' && o.active === 'home' ? frame.shipped({ service: 'tips', title: `Recently shipped on ${SITE_NAME}` }) : ''}
</main>
ddiff --git a/server/web/pages.js b/server/web/pages.js
iindex 24857fa..3a8a697 100644
---- a/server/web/pages.js
++++ b/server/web/pages.js
@@@ -45,18 +45,47 @@ function errorBox(msg) { return msg ? `<p class="notice error" role="alert">${es
const NEW_PAGE_MINIMUMS = Object.freeze({ tip: 1, paid_message: 100, tts: 100, media_request: 25, sound: 50 });
const vibes = (n) => `${n} ${n === 1 ? 'Vibe' : 'Vibes'}`;
--/** The home: the product's own words (openvibe-shared/showcase), then the creators who switched their page on. */
--function home({ creators }) {
++/** Questions people ask, answered from how Tips works (also the home's FAQPage JSON-LD). */
++const HOME_FAQ = Object.freeze([
++ { q: 'What are Vibes?', a: 'Vibes are OpenVibe\'s credits for supporting creators. Your balance is kept by OpenVibe.Billing, and the same Vibes work for tips here and on OpenVibe.Live.' },
++ { q: 'How do I pay?', a: 'From your Vibes balance, or through a checkout on the creator\'s page. OpenVibe.Billing handles every payment, and your receipt at openvibe.tips/receipts shows when it settled and when your message was delivered.' },
++ { q: 'Can I stay anonymous?', a: 'Yes. For each tip you can show your name as "Anonymous" to everyone but yourself, hide the amount from the public, or keep a tip\'s message for the creator only.' },
++ { q: 'Can creators on Twitch, YouTube or Kick use it?', a: 'Yes. The alerts and goal bar are web pages a creator adds to OBS or Streamlabs as a browser source, so they show on any stream. Paid messages also appear in the creator\'s OpenVibe chat.' },
++ { q: 'What can a creator control?', a: 'Their own minimums, whether text-to-speech, media requests and sounds are on, how long a media request may run, a word filter that stars out or holds messages, and moderators who can hide anything that slips through.' },
++ { q: 'Do I need an account?', a: 'Anyone can read a creator\'s page. Sending something needs an OpenVibe account, so your receipts and your privacy choices stay with you.' },
++]);
++
++const SUPPORT_SERVICES = Object.freeze(['Ko-fi', 'PayPal.me', 'Patreon', 'Buy Me a Coffee', 'Cash App', 'Venmo', 'GitHub Sponsors', 'Liberapay', 'Open Collective', 'Streamlabs', 'StreamElements']);
++
++const watching = (v) => (v === 1 ? '1 watching' : `${n(v)} watching`);
++
++/** A titled home section with an optional "see all" link; nothing when it has no body. */
++function homeSection({ id, title, lede, more, body }) {
++ if (!body) return '';
++ return `<section class="tp-sec" aria-labelledby="${esc(id)}"><div class="tp-head"><h2 id="${esc(id)}">${esc(title)}</h2>${more ? `<a class="tp-more" href="${esc(more.href)}">${esc(more.label)}<span aria-hidden="true"> →</span></a>` : ''}</div>${lede ? `<p class="tp-lede">${esc(lede)}</p>` : ''}${body}</section>`;
++}
++
++/**
++ * The home: what Tips is for, who is live on OpenVibe right now, the creators who switched their page on, what a
++ * supporter can send, what a creator gets (overlays for any streaming app, other ways to be paid), what it costs, how it
++ * works and the questions people ask. live: clients/live-now.js; creators: switched-on pages with their active goal.
++ */
++function home({ creators, live = [] }) {
const m = NEW_PAGE_MINIMUMS;
++ const liveCards = live.length ? `<ul class="tp-cards">${live.map((c) => `<li class="tp-card"><p class="tp-card-top"><span class="tp-live">Live</span> <a href="${esc(c.url)}"><strong>${esc(c.name)}</strong></a></p>${c.title ? `<p>${esc(c.title)}</p>` : ''}<p class="meta">${[c.category, watching(c.viewers)].filter(Boolean).map(esc).join(' · ')}</p></li>`).join('')}</ul>` : '';
++ const creatorCards = creators.length ? `<ul class="tp-cards">${creators.map((c) => `<li class="tp-card"><p class="tp-card-top">${c.avatar_url ? `<img src="${esc(c.avatar_url)}" alt="" width="40" height="40" loading="lazy">` : ''}<a href="/${esc(c.handle)}"><strong>${esc(c.display_name)}</strong></a></p>${c.headline ? `<p>${esc(c.headline)}</p>` : ''}${c.goal ? `<p class="meta">Goal: ${esc(c.goal.title)}</p><div class="bar" role="progressbar" aria-label="${esc(c.goal.title)}" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${c.goal.percent}"><span style="width:${c.goal.percent}%"></span></div>` : ''}</li>`).join('')}</ul>` : '';
return showcase.hero({
eyebrow: 'OpenVibe.Tips',
title: 'Support the creators', accent: 'you watch',
-- lede: 'Tip a creator with Vibes, send a highlighted paid message, have your message read out, or request a video for their stream. Creators set goals and show every tip on stream with an overlay.',
-- actions: creators.length
-- ? [{ label: 'Find a creator', href: '#creators', primary: true }, { label: 'Start your tip page', href: '/dashboard' }]
-- : [{ label: 'Start your tip page', href: '/dashboard', primary: true }],
-- note: 'Already sent something? Your receipts are at openvibe.tips/receipts.',
-- }) + showcase.features({
++ lede: 'Tip a creator with Vibes, send a highlighted paid message, have your message read out, request a video or play one of their sounds on stream. Creators set goals and show every tip with overlays that work on any streaming app.',
++ actions: [
++ ...(creators.length ? [{ label: 'Find a creator', href: '#creators', primary: true }] : live.length ? [{ label: 'See who is live', href: '#live', primary: true }] : []),
++ { label: 'Start your tip page', href: '/dashboard', primary: !creators.length && !live.length },
++ { label: 'Your receipts', href: '/receipts' },
++ ],
++ }) + homeSection({ id: 'live', title: 'Live on OpenVibe now', lede: 'Open a channel to watch, chat and support the creator.', more: { href: 'https://openvibe.live/', label: 'Everyone live' }, body: liveCards })
++ + homeSection({ id: 'creators', title: 'Creators on OpenVibe.Tips', lede: 'Every creator who switched their tip page on, the most recently updated first.', body: creatorCards })
++ + showcase.features({
title: 'What you can send',
items: [
{ icon: 'ov:tips', title: 'A tip', text: 'Vibes straight to the creator, with an optional message.' },
@@@ -64,11 +93,24 @@ function home({ creators }) {
{ icon: 'ov:audio', title: 'Text-to-speech', text: 'Your message read out on stream, where the creator turned it on.' },
{ icon: 'ov:video', title: 'A media request', text: 'A video for their stream, up to the length they allow, where they turned it on.' },
{ icon: 'ov:audio', title: 'A sound', text: "One of the creator's sounds, played on stream by their overlay, where they turned it on." },
++ { icon: 'ov:shield', title: 'Your privacy, your choice', text: 'Send as Anonymous, hide the amount, or keep a message for the creator only.' },
++ ],
++ }) + showcase.features({
++ title: 'For creators',
++ lede: 'A tip page at openvibe.tips/your-name, set up from your dashboard in a few minutes.',
++ items: [
++ { icon: 'ov:live', title: 'Overlays for any stream', text: 'Alerts and a goal bar as browser sources for OBS or Streamlabs, on Twitch, YouTube, Kick or OpenVibe.Live.', href: '/dashboard' },
++ { icon: 'ov:check', title: 'Goals', text: 'Set goals with a target; every tip moves the bar on your page and on stream.', href: '/dashboard' },
++ { icon: 'ov:audio', title: 'Sounds and requests', text: 'Upload up to 12 sounds, turn on text-to-speech and media requests, and set your own minimums.', href: '/dashboard' },
++ { icon: 'ov:shield', title: 'Moderation', text: 'A word filter that stars out or holds messages, and moderators who can hide anything that slips through.', href: '/dashboard' },
++ { icon: 'ov:tips', title: 'Other ways to be paid', text: `Link your ${SUPPORT_SERVICES.slice(0, 4).join(', ')} and ${SUPPORT_SERVICES.length - 4} more services beside your tip form.`, href: '/dashboard' },
++ { icon: 'ov:docs', title: 'Receipts both ways', text: 'Supporters keep a receipt for every tip; you see each one settle and reach your stream.' },
],
-- }) + showcase.steps({
++ }) + homeSection({ id: 'services', title: 'Other ways to support', lede: 'A creator can list these beside their tip form. Each link is built from the creator\'s handle on that service\'s own site, and it is paid there, outside OpenVibe.', body: `<ul class="tp-chips">${SUPPORT_SERVICES.map((x) => `<li>${esc(x)}</li>`).join('')}</ul>` })
++ + showcase.steps({
title: 'How it works',
items: [
-- { title: 'Pick a creator', text: 'Every creator who switched their page on has one at openvibe.tips/name.' },
++ { title: 'Pick a creator', text: 'Every creator who switched their page on has one at openvibe.tips/name, linked from their channel.' },
{ title: 'Choose what to send', text: "A tip, a paid message, text-to-speech, a media request or a sound, within the creator's own limits." },
{ title: 'Pay with Vibes', text: 'From your Vibes balance, or through checkout. Payments are handled by OpenVibe.Billing; your receipt shows when the payment settled and when your message was delivered.' },
],
@@@ -83,8 +125,8 @@ function home({ creators }) {
{ name: 'Sound', price: vibes(m.sound), items: ['Played on stream by their overlay', 'Only where the creator turned it on'] },
],
note: "The creator's page shows their own minimums before you pay.",
-- }) + `
--${creators.length ? `<section class="sc-sec" id="creators" aria-labelledby="creators-h"><h2 id="creators-h">Creators on OpenVibe.Tips</h2><ul class="creators">${creators.map((c) => `<li><a href="/${esc(c.handle)}">${c.avatar_url ? `<img src="${esc(c.avatar_url)}" alt="" width="40" height="40" loading="lazy">` : ''}<span>${esc(c.display_name)}</span></a></li>`).join('')}</ul></section>` : ''}`;
++ }) + `<section class="tp-sec" aria-labelledby="faq"><div class="tp-head"><h2 id="faq">Questions and answers</h2></div><div class="tp-faq">${HOME_FAQ.map((x) => `<details><summary>${esc(x.q)}</summary><p>${esc(x.a)}</p></details>`).join('')}</div></section>`
++ + showcase.cta({ title: 'Stream? Get tipped anywhere.', text: 'Start a tip page, add the overlay to your streaming app and share your link.', actions: [{ label: 'Start your tip page', href: '/dashboard', primary: true }, { label: 'Memberships on OpenVibe.VIP', href: 'https://openvibe.vip/' }] });
}
/** The tip form: every field works as a plain POST. */
@@@ -435,4 +477,4 @@ function errorPage({ status, title, message }) {
return `<section class="card"><h1>${esc(title)}</h1><p>${esc(message)}</p><p><a href="/">OpenVibe.Tips home</a></p></section><!-- ${status} -->`;
}
--module.exports = { NEW_PAGE_MINIMUMS, home, creatorPage, goalsPage, supportersPage, receiptsPage, erasePage, receiptPage, dashboard, moderatePage, invitePage, inviteCreated, tokenCreated, errorPage, tipForm };
++module.exports = { NEW_PAGE_MINIMUMS, HOME_FAQ, SUPPORT_SERVICES, home, creatorPage, goalsPage, supportersPage, receiptsPage, erasePage, receiptPage, dashboard, moderatePage, invitePage, inviteCreated, tokenCreated, errorPage, tipForm };
ddiff --git a/server/web/routes.js b/server/web/routes.js
iindex 983c344..144255d 100644
---- a/server/web/routes.js
++++ b/server/web/routes.js
@@@ -27,7 +27,7 @@ const cache = require('openvibe-shared/cache-policy');
const frame = require('openvibe-shared/frame');
const legal = require('openvibe-shared/legal');
const seo = require('openvibe-shared/seo');
--const HOME_DESCRIPTION = 'Support the creators you watch on OpenVibe: tips, goals, paid messages, text-to-speech and media requests, with stream overlays for creators.';
++const HOME_DESCRIPTION = 'Tip the creators you watch: paid messages, text-to-speech, media requests and sounds, with goals and alert overlays for OBS on any streaming app.';
const express = require('express');
const { sql } = require('openvibe-sdk/db');
const { TipsError } = require('../util');
@@@ -51,7 +51,7 @@ const FLASH = new Set(['Saved', 'Page settings saved', 'Goal added', 'Goal updat
const MOD_FLASH = new Set(['Hidden', 'Shown', 'You are now a moderator']);
const RECEIPT_FLASH = new Set(['Your data was erased from your tips']);
--function createWebRoutes({ domain, config, layout, userAuth }) {
++function createWebRoutes({ domain, config, layout, userAuth, liveNow = { list: async () => [] } }) {
// Every handler below may be async: its errors reach the app's error handler (the 500 page).
const r = asyncRouter(express.Router());
const { profiles, goals, interactions, overlays, moderation, sounds, db } = domain;
@@@ -81,13 +81,23 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
// ── Home, robots, sitemap ────────────────────────────────
r.get('/', withViewer, async (req, res) => {
-- const creators = await db.many(sql`SELECT handle, display_name, avatar_url FROM creator_tip_profiles WHERE page_enabled ORDER BY updated_at DESC LIMIT 24`);
-- // What the site is, for search engines (WebSite + WebPage, as the other OpenVibe homes say it).
++ const [rows, live] = await Promise.all([
++ db.many(sql`SELECT creator_subject, handle, display_name, avatar_url, headline FROM creator_tip_profiles WHERE page_enabled ORDER BY updated_at DESC LIMIT 24`),
++ liveNow.list(),
++ ]);
++ // Each creator's first active goal: its title and percent only (a public goal bar shows both, amounts or not).
++ const creators = await Promise.all(rows.map(async (p) => {
++ const [goal] = await goals.presentMany(db, (await goals.list(db, p.creator_subject, { status: 'active' })).slice(0, 1));
++ return { handle: p.handle, display_name: p.display_name, avatar_url: p.avatar_url, headline: p.headline, goal: goal ? { title: goal.title, percent: goal.percent } : null };
++ }));
++ // What the site is, for search engines (WebSite + WebPage, as the other OpenVibe homes say it), and the questions.
const jsonLd = [
seo.jsonLd.website({ name: 'OpenVibe.Tips', url: config.baseUrl, description: HOME_DESCRIPTION }),
seo.jsonLd.webPage({ name: 'OpenVibe.Tips', url: `${config.baseUrl}/`, description: HOME_DESCRIPTION, siteUrl: config.baseUrl }),
++ { '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: pages.HOME_FAQ.map((x) => ({ '@type': 'Question', name: x.q, acceptedAnswer: { '@type': 'Answer', text: x.a } })) },
];
-- html(res, pageFor(req, { active: 'home', canonicalPath: '/', styles: [require('openvibe-shared/showcase').STYLESHEET], body: pages.home({ creators }), jsonLd }));
++ // Who is live comes from Live once a minute, so the page may be cached as long.
++ html(res, pageFor(req, { active: 'home', canonicalPath: '/', title: 'Tips, paid messages and stream alerts', description: HOME_DESCRIPTION, styles: [require('openvibe-shared/showcase').STYLESHEET], wide: true, body: pages.home({ creators, live }), jsonLd }));
});
// What shipped on OpenVibe.Tips: the shared update log every OpenVibe site has.
r.get('/updates', withViewer, (req, res) => html(res, pageFor(req, { canonicalPath: '/updates', title: 'What shipped on OpenVibe.Tips', body: frame.updatesBody({ service: 'tips', siteName: 'OpenVibe.Tips' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>` })));
ddiff --git a/test/home.test.js b/test/home.test.js
nnew file mode 100644
iindex 0000000..e149479
---- /dev/null
++++ b/test/home.test.js
@@@ -0,0 +1,60 @@
++'use strict';
++/**
++ * The front page: who is live on OpenVibe (clients/live-now.js, Live's public stream list, cached a minute, never
++ * Live's AI text, nothing when Live fails), its questions with their FAQPage JSON-LD, no filler.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/app');
++const { createLiveNow } = require('../server/clients/live-now');
++
++(async () => {
++ await check('live-now keeps only what a card needs, the most watched first, and caches for a minute', async () => {
++ let calls = 0;
++ let clock = 1_000_000;
++ const fetchImpl = async (url) => {
++ calls++;
++ assert.strictEqual(url, 'https://live.test/api/streams');
++ return { ok: true, json: async () => ({ streams: [
++ { is_live: 1, username: 'quiet', display_name: 'Quiet One', title: 'Reading', category: 'books', viewer_count: 1, ai_overview: 'never shown' },
++ { is_live: 1, username: 'busy_1', display_name: 'Busy', title: 'Speedrun', chosen_category: 'gaming', viewer_count: 40 },
++ { is_live: 1, is_nsfw: 1, username: 'nsfw', viewer_count: 99 },
++ { is_live: 0, username: 'offline', viewer_count: 5 },
++ { is_live: 1, username: 'bad name!', viewer_count: 7 },
++ ] }) };
++ };
++ const live = createLiveNow({ config: { liveNow: true, liveUrl: 'https://live.test' }, fetchImpl, now: () => clock, log: { warn() {} } });
++ const list = await live.list();
++ assert.deepStrictEqual(list.map((x) => x.username), ['busy_1', 'quiet'], 'live, safe-for-work, valid names, most watched first');
++ assert.deepStrictEqual(list[0], { username: 'busy_1', name: 'Busy', title: 'Speedrun', category: 'gaming', viewers: 40, started_at: null, url: 'https://live.test/@busy_1' });
++ assert.ok(!JSON.stringify(list).includes('never shown'), 'no AI text');
++ await live.list();
++ assert.strictEqual(calls, 1, 'cached');
++ clock += 61_000;
++ await live.list();
++ assert.strictEqual(calls, 2, 'refreshed after a minute');
++ const down = createLiveNow({ config: { liveNow: true, liveUrl: 'https://live.test' }, fetchImpl: async () => { throw new Error('down'); }, log: { warn() {} } });
++ assert.deepStrictEqual(await down.list(), [], 'Live down: nothing, no error');
++ const off = createLiveNow({ config: { liveNow: false, liveUrl: 'https://live.test' }, fetchImpl: async () => { throw new Error('never called'); } });
++ assert.deepStrictEqual(await off.list(), []);
++ });
++
++ const t = await boot({ appOpts: { liveNow: { list: async () => [{ username: 'busy_1', name: 'Busy', title: 'Speedrun', category: 'gaming', viewers: 40, url: 'https://openvibe.live/@busy_1' }] } } });
++ await check('the home shows who is live, what creators get, the services and the questions (FAQPage JSON-LD)', async () => {
++ const r = await fetch(`${t.base}/`);
++ const text = await r.text();
++ const body = text.slice(text.indexOf('<main'));
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual((text.match(/<h1[\s>]/g) || []).length, 1, 'one h1');
++ assert.ok(body.includes('id="live"') && body.includes('<a href="https://openvibe.live/@busy_1"><strong>Busy</strong></a>') && body.includes('gaming · 40 watching'));
++ assert.ok(body.includes('For creators') && body.includes('Overlays for any stream'));
++ assert.ok(body.includes('<li>Ko-fi</li>') && body.includes('<li>StreamElements</li>'), 'the services a creator can link');
++ const ld = [...text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
++ const faq = ld.find((x) => x['@type'] === 'FAQPage');
++ assert.ok(faq && faq.mainEntity.length === 6);
++ const missing = faq.mainEntity.filter((q) => !body.includes(`<summary>${q.name.replace(/'/g, '&#39;')}</summary>`));
++ assert.deepStrictEqual(missing, [], 'every question is on the page');
++ assert.doesNotMatch(body, /coming soon/i);
++ });
++ await t.close();
++ done();
++})();
ddiff --git a/test/pages.test.js b/test/pages.test.js
iindex 052b104..6ec8ff1 100644
---- a/test/pages.test.js
++++ b/test/pages.test.js
@@@ -76,10 +76,10 @@ const { boot, check, done } = require('./helpers/app');
const home = await get('/');
const head = home.text.slice(0, home.text.indexOf('</head>'));
assert.strictEqual(head.match(/<title>/g).length, 1, 'exactly one <title>');
-- assert.match(head, /<title>OpenVibe\.Tips — support the creators you watch<\/title>/);
++ assert.match(head, /<title>Tips, paid messages and stream alerts · OpenVibe\.Tips<\/title>/);
assert.match(head, /<link rel="canonical" href="http:\/\/tips\.test\/">/);
assert.match(head, /<meta name="robots" content="index,follow">/);
-- assert.match(head, /<meta property="og:title" content="OpenVibe\.Tips — support the creators you watch">/);
++ assert.match(head, /<meta property="og:title" content="Tips, paid messages and stream alerts[^"]*">/);
assert.match(head, /<meta name="referrer" content="strict-origin-when-cross-origin">/);
assert.match(head, /<link rel="stylesheet" href="\/css\/tips\.css\?v=[^"]+">/);
assert.match(head, /<link rel="icon"[^>]*data-ov-icon="tips"/, 'the app-icon tags');
@@@ -90,7 +90,7 @@ const { boot, check, done } = require('./helpers/app');
for (const s of ['theme-loader', 'navbar', 'footer']) assert.match(head, new RegExp(`/shared/${s}\\.js\\?v=[0-9a-f]{12}`), s);
assert.match(home.text, /<body data-page="home">/);
assert.match(home.text, /<header class="site-head">/);
-- assert.match(home.text, /<main id="main" class="page">/);
++ assert.match(home.text, /<main id="main" class="page page-wide">/, "the home is the wide page");
assert.match(home.text, /OpenVibeFooter\.init\(window\.__OV_PAGE\.footer\)/);
const creator = await get('/alex');