'use strict'; /** * openvibe-shared/vibes-pay.js — Vibes Pay for any page (MONEY.md, D12). * * * * * * OpenVibe.vibes.pay(payUrl).then(({ status }) => { … }); * * A button with data-ov-vibes-pay binds itself: it is styled as "Pay with Vibes" (the Vibes green, * readable on light and dark themes), and a click opens the sheet in a popup on a computer and as a * full page on a phone. The sheet posts { type: 'ov-vibes-pay', status, charge_id } to the page, only * from https://openvibe.network; the promise resolves with { status } (and charge_id when sent), and * a page may listen for the bubbling 'ov-vibes-pay' event instead. A popup closed without a payment * resolves { status: 'closed' }. * * The sheet itself is openvibe.network/pay/; a pay URL on another origin is refused. * The button is progressive: without JavaScript (or this file) the link can still be a plain anchor. */ (function (root, factory) { const api = factory(root); if (typeof module === 'object' && module.exports) { module.exports = api; return; } root.OpenVibe = root.OpenVibe || {}; root.OpenVibe.vibes = Object.assign(root.OpenVibe.vibes || {}, api); root.OVVibesPay = api; })(typeof globalThis !== 'undefined' ? globalThis : this, function (root) { 'use strict'; const ORIGIN = 'https://openvibe.network'; const TYPE = 'ov-vibes-pay'; const STYLE_ID = 'ov-vibes-pay-style'; const POPUP = 'ov-vibes-pay'; const PHONE = '(max-width: 640px)'; let pending = null; const isPhone = () => { try { return !!(root.matchMedia && root.matchMedia(PHONE).matches); } catch { return false; } }; /** `payUrl` must be an https URL on openvibe.network (a relative /pay/… path is taken as one). */ function payTarget(payUrl) { let u; try { u = new URL(String(payUrl), ORIGIN); } catch { throw new TypeError('vibes.pay: payUrl is a URL'); } if (u.protocol !== 'https:' || u.origin !== ORIGIN) throw new TypeError(`vibes.pay: payUrl must be on ${ORIGIN}`); if (!/^\/pay\/[^/]+/.test(u.pathname)) throw new TypeError('vibes.pay: payUrl is an openvibe.network/pay/… URL'); return u.href; } function emit(result) { const doc = root.document; if (!doc || typeof root.CustomEvent !== 'function') return; try { doc.dispatchEvent(new root.CustomEvent(TYPE, { detail: result, bubbles: true })); } catch { /* an old browser */ } } function settle(result) { const p = pending; pending = null; if (p && p.timer) clearInterval(p.timer); if (p) { p.resolve(result); emit(result); } } function onMessage(event) { if (!event || event.origin !== ORIGIN) return; const data = event.data; if (!data || typeof data !== 'object' || data.type !== TYPE) return; if (typeof data.status !== 'string' || !data.status) return; if (!pending) return; // A popup: only the window we opened may answer. A page that did not keep the WindowProxy skips this. if (pending.popup && event.source && event.source !== pending.popup) return; const result = { status: data.status, pay_url: pending.href }; if (data.charge_id) result.charge_id = data.charge_id; settle(result); } /** * Open the Vibes Pay sheet and resolve { status } (plus charge_id/pay_url when known). * On a phone it is a full page in a new tab; if the browser blocks it, this page navigates instead. */ function pay(payUrl) { const href = payTarget(payUrl); if (pending) return pending.promise; if (!root || typeof root.open !== 'function') throw new TypeError('vibes.pay: this page cannot open a window'); let resolve; const promise = new Promise((r) => { resolve = r; }); let popup = null; if (isPhone()) { popup = root.open(href, '_blank'); } else { const w = Math.min(520, Math.max(360, Number(root.innerWidth) || 1200)); const h = Math.min(760, Math.max(560, Number(root.innerHeight) || 800)); const left = Math.max(0, Math.round((Number(root.screenX) || 0) + ((Number(root.outerWidth) || w) - w) / 2)); const top = Math.max(0, Math.round((Number(root.screenY) || 0) + ((Number(root.outerHeight) || h) - h) / 2)); popup = root.open(href, POPUP, `popup=yes,width=${w},height=${h},left=${left},top=${top}`); } if (!popup) { // A blocked popup is not an error: the sheet is a normal page too. if (root.location && typeof root.location.assign === 'function') root.location.assign(href); return promise; } try { popup.focus(); } catch { /* some browsers refuse focus() */ } pending = { href, popup, resolve, promise, timer: null }; pending.timer = setInterval(() => { if (pending && pending.popup && pending.popup.closed) settle({ status: 'closed', pay_url: href }); }, 700); if (pending.timer && typeof pending.timer.unref === 'function') pending.timer.unref(); return promise; } const CSS = ` .ov-vibes-pay { --ov-vibes-accent: #4ade80; --ov-vibes-accent-light: #86efac; --ov-vibes-ink: #07130a; display: inline-flex; align-items: center; justify-content: center; gap: .5em; min-height: 44px; padding: .62em 1.15em; border: 1px solid rgba(34, 197, 94, .55); border-radius: 999px; background: var(--ov-vibes-accent); color: var(--ov-vibes-ink); font: inherit; font-weight: 700; line-height: 1.2; text-decoration: none; cursor: pointer; box-shadow: 0 6px 18px -10px rgba(34, 197, 94, .8); -webkit-tap-highlight-color: transparent; } .ov-vibes-pay:hover { background: var(--ov-vibes-accent-light); } .ov-vibes-pay:focus-visible { outline: 3px solid color-mix(in srgb, var(--ov-vibes-accent) 55%, transparent); outline-offset: 2px; } .ov-vibes-pay:disabled, .ov-vibes-pay[aria-busy="true"] { opacity: .62; cursor: progress; } .ov-vibes-pay { transition: background-color .15s ease; } `; function ensureStyles() { const doc = root.document; if (!doc || doc.getElementById(STYLE_ID)) return; const style = doc.createElement('style'); style.id = STYLE_ID; style.textContent = CSS; (doc.head || doc.documentElement).appendChild(style); } /** * Bind every [data-ov-vibes-pay] under `scope` (default: the document) once. A bound element is * labelled "Pay with Vibes", disabled while the sheet is open, and dispatches the page event. * Returns how many were bound now. */ function bind(scope) { const doc = root.document; if (!doc) return 0; const where = scope || doc; if (!where || typeof where.querySelectorAll !== 'function') return 0; let bound = 0; for (const el of where.querySelectorAll('[data-ov-vibes-pay]')) { if (el.__ovVibesPayBound) continue; const url = el.getAttribute('data-ov-vibes-pay'); if (!url) continue; el.__ovVibesPayBound = true; ensureStyles(); el.classList.add('ov-vibes-pay'); if (String(el.tagName).toUpperCase() === 'BUTTON' && !el.getAttribute('type')) el.setAttribute('type', 'button'); el.textContent = 'Pay with Vibes'; el.addEventListener('click', async (event) => { event.preventDefault(); if (el.disabled) return; el.disabled = true; el.setAttribute('aria-busy', 'true'); try { await pay(url); } catch (err) { if (root.console && root.console.error) root.console.error('[vibes-pay]', err); } finally { el.disabled = false; el.removeAttribute('aria-busy'); } }); bound++; } return bound; } function autoInit() { const doc = root.document; if (!doc) return; const run = () => { ensureStyles(); bind(doc); }; if (doc.readyState === 'loading') doc.addEventListener('DOMContentLoaded', run, { once: true }); else run(); if (typeof root.MutationObserver === 'function' && doc.documentElement) { try { new root.MutationObserver(() => bind(doc)).observe(doc.documentElement, { childList: true, subtree: true }); } catch { /* no observer, the initial bind still ran */ } } } if (root && typeof root.addEventListener === 'function' && !root.__ovVibesPayListening) { root.__ovVibesPayListening = true; root.addEventListener('message', onMessage); autoInit(); } return { pay, bind, ORIGIN, TYPE }; });