openvibers/openvibe.shared vibes-pay.js

vibes-pay.js

main 8.9 KB · 209 lines · javascript

1'use strict';
2/**
3 * openvibe-shared/vibes-pay.js — Vibes Pay for any page (MONEY.md, D12).
4 *
5 * <script src="https://openvibe.network/shared/vibes-pay.js"></script>
6 *
7 * <button data-ov-vibes-pay="https://openvibe.network/pay/vch_…">Pay with Vibes</button>
8 *
9 * OpenVibe.vibes.pay(payUrl).then(({ status }) => { … });
10 *
11 * A button with data-ov-vibes-pay binds itself: it is styled as "Pay with Vibes" (the Vibes green,
12 * readable on light and dark themes), and a click opens the sheet in a popup on a computer and as a
13 * full page on a phone. The sheet posts { type: 'ov-vibes-pay', status, charge_id } to the page, only
14 * from https://openvibe.network; the promise resolves with { status } (and charge_id when sent), and
15 * a page may listen for the bubbling 'ov-vibes-pay' event instead. A popup closed without a payment
16 * resolves { status: 'closed' }.
17 *
18 * The sheet itself is openvibe.network/pay/<charge id>; a pay URL on another origin is refused.
19 * The button is progressive: without JavaScript (or this file) the link can still be a plain anchor.
20 */
21(function (root, factory) {
22 const api = factory(root);
23 if (typeof module === 'object' && module.exports) { module.exports = api; return; }
24 root.OpenVibe = root.OpenVibe || {};
25 root.OpenVibe.vibes = Object.assign(root.OpenVibe.vibes || {}, api);
26 root.OVVibesPay = api;
27})(typeof globalThis !== 'undefined' ? globalThis : this, function (root) {
28 'use strict';
29
30 const ORIGIN = 'https://openvibe.network';
31 const TYPE = 'ov-vibes-pay';
32 const STYLE_ID = 'ov-vibes-pay-style';
33 const POPUP = 'ov-vibes-pay';
34 const PHONE = '(max-width: 640px)';
35
36 let pending = null;
37
38 const isPhone = () => {
39 try { return !!(root.matchMedia && root.matchMedia(PHONE).matches); } catch { return false; }
40 };
41
42 /** `payUrl` must be an https URL on openvibe.network (a relative /pay/… path is taken as one). */
43 function payTarget(payUrl) {
44 let u;
45 try { u = new URL(String(payUrl), ORIGIN); } catch { throw new TypeError('vibes.pay: payUrl is a URL'); }
46 if (u.protocol !== 'https:' || u.origin !== ORIGIN) throw new TypeError(`vibes.pay: payUrl must be on ${ORIGIN}`);
47 if (!/^\/pay\/[^/]+/.test(u.pathname)) throw new TypeError('vibes.pay: payUrl is an openvibe.network/pay/… URL');
48 return u.href;
49 }
50
51 function emit(result) {
52 const doc = root.document;
53 if (!doc || typeof root.CustomEvent !== 'function') return;
54 try { doc.dispatchEvent(new root.CustomEvent(TYPE, { detail: result, bubbles: true })); } catch { /* an old browser */ }
55 }
56
57 function settle(result) {
58 const p = pending;
59 pending = null;
60 if (p && p.timer) clearInterval(p.timer);
61 if (p) { p.resolve(result); emit(result); }
62 }
63
64 function onMessage(event) {
65 if (!event || event.origin !== ORIGIN) return;
66 const data = event.data;
67 if (!data || typeof data !== 'object' || data.type !== TYPE) return;
68 if (typeof data.status !== 'string' || !data.status) return;
69 if (!pending) return;
70 // A popup: only the window we opened may answer. A page that did not keep the WindowProxy skips this.
71 if (pending.popup && event.source && event.source !== pending.popup) return;
72 const result = { status: data.status, pay_url: pending.href };
73 if (data.charge_id) result.charge_id = data.charge_id;
74 settle(result);
75 }
76
77 /**
78 * Open the Vibes Pay sheet and resolve { status } (plus charge_id/pay_url when known).
79 * On a phone it is a full page in a new tab; if the browser blocks it, this page navigates instead.
80 */
81 function pay(payUrl) {
82 const href = payTarget(payUrl);
83 if (pending) return pending.promise;
84 if (!root || typeof root.open !== 'function') throw new TypeError('vibes.pay: this page cannot open a window');
85 let resolve;
86 const promise = new Promise((r) => { resolve = r; });
87 let popup = null;
88 if (isPhone()) {
89 popup = root.open(href, '_blank');
90 } else {
91 const w = Math.min(520, Math.max(360, Number(root.innerWidth) || 1200));
92 const h = Math.min(760, Math.max(560, Number(root.innerHeight) || 800));
93 const left = Math.max(0, Math.round((Number(root.screenX) || 0) + ((Number(root.outerWidth) || w) - w) / 2));
94 const top = Math.max(0, Math.round((Number(root.screenY) || 0) + ((Number(root.outerHeight) || h) - h) / 2));
95 popup = root.open(href, POPUP, `popup=yes,width=${w},height=${h},left=${left},top=${top}`);
96 }
97 if (!popup) {
98 // A blocked popup is not an error: the sheet is a normal page too.
99 if (root.location && typeof root.location.assign === 'function') root.location.assign(href);
100 return promise;
101 }
102 try { popup.focus(); } catch { /* some browsers refuse focus() */ }
103 pending = { href, popup, resolve, promise, timer: null };
104 pending.timer = setInterval(() => {
105 if (pending && pending.popup && pending.popup.closed) settle({ status: 'closed', pay_url: href });
106 }, 700);
107 if (pending.timer && typeof pending.timer.unref === 'function') pending.timer.unref();
108 return promise;
109 }
110
111 const CSS = `
112.ov-vibes-pay {
113 --ov-vibes-accent: #4ade80;
114 --ov-vibes-accent-light: #86efac;
115 --ov-vibes-ink: #07130a;
116 display: inline-flex;
117 align-items: center;
118 justify-content: center;
119 gap: .5em;
120 min-height: 44px;
121 padding: .62em 1.15em;
122 border: 1px solid rgba(34, 197, 94, .55);
123 border-radius: 999px;
124 background: var(--ov-vibes-accent);
125 color: var(--ov-vibes-ink);
126 font: inherit;
127 font-weight: 700;
128 line-height: 1.2;
129 text-decoration: none;
130 cursor: pointer;
131 box-shadow: 0 6px 18px -10px rgba(34, 197, 94, .8);
132 -webkit-tap-highlight-color: transparent;
133}
134.ov-vibes-pay:hover { background: var(--ov-vibes-accent-light); }
135.ov-vibes-pay:focus-visible { outline: 3px solid color-mix(in srgb, var(--ov-vibes-accent) 55%, transparent); outline-offset: 2px; }
136.ov-vibes-pay:disabled, .ov-vibes-pay[aria-busy="true"] { opacity: .62; cursor: progress; }
137.ov-vibes-pay { transition: background-color .15s ease; }
138`;
139
140 function ensureStyles() {
141 const doc = root.document;
142 if (!doc || doc.getElementById(STYLE_ID)) return;
143 const style = doc.createElement('style');
144 style.id = STYLE_ID;
145 style.textContent = CSS;
146 (doc.head || doc.documentElement).appendChild(style);
147 }
148
149 /**
150 * Bind every [data-ov-vibes-pay] under `scope` (default: the document) once. A bound element is
151 * labelled "Pay with Vibes", disabled while the sheet is open, and dispatches the page event.
152 * Returns how many were bound now.
153 */
154 function bind(scope) {
155 const doc = root.document;
156 if (!doc) return 0;
157 const where = scope || doc;
158 if (!where || typeof where.querySelectorAll !== 'function') return 0;
159 let bound = 0;
160 for (const el of where.querySelectorAll('[data-ov-vibes-pay]')) {
161 if (el.__ovVibesPayBound) continue;
162 const url = el.getAttribute('data-ov-vibes-pay');
163 if (!url) continue;
164 el.__ovVibesPayBound = true;
165 ensureStyles();
166 el.classList.add('ov-vibes-pay');
167 if (String(el.tagName).toUpperCase() === 'BUTTON' && !el.getAttribute('type')) el.setAttribute('type', 'button');
168 el.textContent = 'Pay with Vibes';
169 el.addEventListener('click', async (event) => {
170 event.preventDefault();
171 if (el.disabled) return;
172 el.disabled = true;
173 el.setAttribute('aria-busy', 'true');
174 try {
175 await pay(url);
176 } catch (err) {
177 if (root.console && root.console.error) root.console.error('[vibes-pay]', err);
178 } finally {
179 el.disabled = false;
180 el.removeAttribute('aria-busy');
181 }
182 });
183 bound++;
184 }
185 return bound;
186 }
187
188 function autoInit() {
189 const doc = root.document;
190 if (!doc) return;
191 const run = () => { ensureStyles(); bind(doc); };
192 if (doc.readyState === 'loading') doc.addEventListener('DOMContentLoaded', run, { once: true });
193 else run();
194 if (typeof root.MutationObserver === 'function' && doc.documentElement) {
195 try {
196 new root.MutationObserver(() => bind(doc)).observe(doc.documentElement, { childList: true, subtree: true });
197 } catch { /* no observer, the initial bind still ran */ }
198 }
199 }
200
201 if (root && typeof root.addEventListener === 'function' && !root.__ovVibesPayListening) {
202 root.__ovVibesPayListening = true;
203 root.addEventListener('message', onMessage);
204 autoInit();
205 }
206
207 return { pay, bind, ORIGIN, TYPE };
208});
209