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)
Changes
@@@ -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) andthe 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 thepage 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 deliveryshown separately, with the privacy chosen), `/receipts/export`, `/receipts/erase` · `/moderate/<handle>`@@@ -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; } }@@@ -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 });nnew file mode 100644@@@ -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 };@@@ -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'),};@@@ -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>@@@ -45,18 +45,47 @@ function errorBox(msg) { return msg ? `<p class="notice error" role="alert">${esconst 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 };@@@ -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 updatconst 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>` })));nnew file mode 100644@@@ -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, ''')}</summary>`));++ assert.deepStrictEqual(missing, [], 'every question is on the page');++ assert.doesNotMatch(body, /coming soon/i);++ });++ await t.close();++ done();++})();@@@ -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');
@@ -48,7 +48,9 @@ from *interaction delivered*.
@@ -48,7 +48,9 @@ from *interaction delivered*.
(openvibe-sdk transactional outbox)
(openvibe-sdk transactional outbox)
- **OpenVibe.Network** — SSO for people, service tokens, JWKS, identity resolve (a creator's Chat room)
- **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.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
- **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 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
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`).
@@ -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)
## 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` ·
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
`/<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>`
shown separately, with the privacy chosen), `/receipts/export`, `/receipts/erase` · `/moderate/<handle>`
@@ -91,3 +91,26 @@ ul.mod-list, ul.plain { list-style: none; padding: 0; }
@@ -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)); }
.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)); } }
@media (min-width: 640px) { .page .sc-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
.page .sc-price { font-size: 24px; }
.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; } }
@@ -138,7 +138,8 @@ function createApp(opts = {}) {
@@ -138,7 +138,8 @@ 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('./clients/live-now').createLiveNow({ config, fetchImpl: opts.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 });
new file mode 100644
new file mode 100644
@@ -0,0 +1,60 @@
@@ -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 };
@@ -135,6 +135,8 @@ function loadConfig(env = process.env) {
@@ -135,6 +135,8 @@ function loadConfig(env = process.env) {
intervalMs: int(env.TIPS_JOBS_INTERVAL_MS, 2000),
intervalMs: int(env.TIPS_JOBS_INTERVAL_MS, 2000),
},
},
liveUrl: trim(env.LIVE_URL || 'https://openvibe.live'),
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).
// 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'),
mediaHosts: list(env.TIPS_MEDIA_HOSTS || 'youtube.com,www.youtube.com,m.youtube.com,music.youtube.com,youtu.be'),
};
};
@@ -47,7 +47,7 @@ function createLayout({ config, release }) {
@@ -47,7 +47,7 @@ function createLayout({ config, release }) {
/**
/**
* o: title, description, canonicalPath, robots ('index,follow' | 'noindex,nofollow'), body,
* 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) {
function page(o) {
const description = (o.description || DEFAULT_DESCRIPTION).replace(/\s+/g, ' ').trim().slice(0, 300);
const description = (o.description || DEFAULT_DESCRIPTION).replace(/\s+/g, ' ').trim().slice(0, 300);
@@ -89,7 +89,7 @@ function createLayout({ config, release }) {
@@ -89,7 +89,7 @@ function createLayout({ config, release }) {
].filter(Boolean).join('\n'),
].filter(Boolean).join('\n'),
body: `<div id="navbar-mount"></div>
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>
<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.body || ''}
${o.canonicalPath === '/' && o.active === 'home' ? frame.shipped({ service: 'tips', title: `Recently shipped on ${SITE_NAME}` }) : ''}
${o.canonicalPath === '/' && o.active === 'home' ? frame.shipped({ service: 'tips', title: `Recently shipped on ${SITE_NAME}` }) : ''}
</main>
</main>
@@ -45,18 +45,47 @@ function errorBox(msg) { return msg ? `<p class="notice error" role="alert">${es
@@ -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 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'}`;
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 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({
return showcase.hero({
eyebrow: 'OpenVibe.Tips',
eyebrow: 'OpenVibe.Tips',
title: 'Support the creators', accent: 'you watch',
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',
title: 'What you can send',
items: [
items: [
{ icon: 'ov:tips', title: 'A tip', text: 'Vibes straight to the creator, with an optional message.' },
{ icon: 'ov:tips', title: 'A tip', text: 'Vibes straight to the creator, with an optional message.' },
@@ -64,11 +93,24 @@ function home({ creators }) {
@@ -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: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: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: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',
title: 'How it works',
items: [
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: '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.' },
{ 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 }) {
@@ -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'] },
{ 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.",
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. */
/** The tip form: every field works as a plain POST. */
@@ -435,4 +477,4 @@ function errorPage({ status, title, message }) {
@@ -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} -->`;
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 };
@@ -27,7 +27,7 @@ const cache = require('openvibe-shared/cache-policy');
@@ -27,7 +27,7 @@ const cache = require('openvibe-shared/cache-policy');
const frame = require('openvibe-shared/frame');
const frame = require('openvibe-shared/frame');
const legal = require('openvibe-shared/legal');
const legal = require('openvibe-shared/legal');
const seo = require('openvibe-shared/seo');
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 express = require('express');
const { sql } = require('openvibe-sdk/db');
const { sql } = require('openvibe-sdk/db');
const { TipsError } = require('../util');
const { TipsError } = require('../util');
@@ -51,7 +51,7 @@ const FLASH = new Set(['Saved', 'Page settings saved', 'Goal added', 'Goal updat
@@ -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 MOD_FLASH = new Set(['Hidden', 'Shown', 'You are now a moderator']);
const RECEIPT_FLASH = new Set(['Your data was erased from your tips']);
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).
// Every handler below may be async: its errors reach the app's error handler (the 500 page).
const r = asyncRouter(express.Router());
const r = asyncRouter(express.Router());
const { profiles, goals, interactions, overlays, moderation, sounds, db } = domain;
const { profiles, goals, interactions, overlays, moderation, sounds, db } = domain;
@@ -81,13 +81,23 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
@@ -81,13 +81,23 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
// ── Home, robots, sitemap ────────────────────────────────
// ── Home, robots, sitemap ────────────────────────────────
r.get('/', withViewer, async (req, res) => {
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 = [
const jsonLd = [
seo.jsonLd.website({ name: 'OpenVibe.Tips', url: config.baseUrl, description: HOME_DESCRIPTION }),
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 }),
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.
// 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>` })));
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>` })));
new file mode 100644
new file mode 100644
@@ -0,0 +1,60 @@
@@ -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, ''')}</summary>`));
+ assert.deepStrictEqual(missing, [], 'every question is on the page');
+ assert.doesNotMatch(body, /coming soon/i);
+ });
+ await t.close();
+ done();
+})();
@@ -76,10 +76,10 @@ const { boot, check, done } = require('./helpers/app');
@@ -76,10 +76,10 @@ const { boot, check, done } = require('./helpers/app');
const home = await get('/');
const home = await get('/');
const head = home.text.slice(0, home.text.indexOf('</head>'));
const head = home.text.slice(0, home.text.indexOf('</head>'));
assert.strictEqual(head.match(/<title>/g).length, 1, 'exactly one <title>');
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, /<link rel="canonical" href="http:\/\/tips\.test\/">/);
assert.match(head, /<meta name="robots" content="index,follow">/);
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, /<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="stylesheet" href="\/css\/tips\.css\?v=[^"]+">/);
assert.match(head, /<link rel="icon"[^>]*data-ov-icon="tips"/, 'the app-icon tags');
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');
@@ -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);
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, /<body data-page="home">/);
assert.match(home.text, /<header class="site-head">/);
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\)/);
assert.match(home.text, /OpenVibeFooter\.init\(window\.__OV_PAGE\.footer\)/);
const creator = await get('/alex');
const creator = await get('/alex');