mnfst 0.5.119 → 0.5.122

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,583 @@
1
+ /* Manifest Payments
2
+ /* By Andrew Matlock under MIT license
3
+ /* https://manifestx.dev
4
+ /*
5
+ /* Provider-agnostic payments surface (x-pay / $pay).
6
+ /* The client only ever talks to YOUR function endpoint — never a provider's
7
+ /* secret API directly. Session creation, fulfilment webhooks and any mutation
8
+ /* live server-side. See manifest.payments.core.js for the contract.
9
+ */
10
+
11
+ /* Payments config */
12
+
13
+ // Refuse strings still containing an unresolved ${VAR}. The loader interpolates
14
+ // against window.env before caching the manifest, so a literal ${VAR} here means
15
+ // an undefined env var — fail loud rather than POST it to the function verbatim.
16
+ function resolvedOrNull(value, fieldName) {
17
+ if (typeof value !== 'string') return value;
18
+ if (/\$\{[^}]+\}/.test(value)) {
19
+ console.error(`[Manifest Payments] manifest.payments.${fieldName} references an undefined env var (${value}).`);
20
+ return null;
21
+ }
22
+ return value;
23
+ }
24
+
25
+ async function ensureManifest() {
26
+ if (window.ManifestComponentsRegistry?.manifest) return window.ManifestComponentsRegistry.manifest;
27
+ if (window.__manifestLoaded) return window.__manifestLoaded;
28
+ try {
29
+ const url = document.querySelector('link[rel="manifest"]')?.getAttribute('href') || '/manifest.json';
30
+ const res = await fetch(url);
31
+ return await res.json();
32
+ } catch (_) {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ // Normalize manifest.payments into a stable config object.
38
+ // provider default adapter key (e.g. "revolut", "stripe", "paddle")
39
+ // endpoint YOUR function base — mints checkout/portal sessions, verifies webhooks
40
+ // mode default modality hint: "redirect" | "overlay" (server may override)
41
+ // publicKey publishable key for overlay SDK init (safe to expose; NOT a secret key)
42
+ // managed true = Manifest-hosted server moment (endpoint inferred)
43
+ // state optional reactive-state source for $pay.state: { url } (GET) — for
44
+ // managed mode. Appwrite-backed projects read state via $x instead.
45
+ let _cache = null;
46
+ async function getPaymentsConfig() {
47
+ if (_cache) return _cache;
48
+ const manifest = await ensureManifest();
49
+ if (!manifest?.payments || typeof manifest.payments !== 'object') return null;
50
+ const p = manifest.payments;
51
+
52
+ const endpoint = p.endpoint ? resolvedOrNull(p.endpoint, 'endpoint') : null;
53
+ const publicKey = p.publicKey ? resolvedOrNull(p.publicKey, 'publicKey') : null;
54
+ if (p.endpoint && endpoint === null) return null;
55
+ if (p.publicKey && publicKey === null) return null;
56
+
57
+ _cache = {
58
+ provider: p.provider || null,
59
+ endpoint,
60
+ mode: p.mode === 'overlay' ? 'overlay' : 'redirect',
61
+ publicKey,
62
+ managed: p.managed === true,
63
+ environment: p.environment || (p.managed ? 'live' : 'sandbox'),
64
+ state: p.state && typeof p.state === 'object' ? p.state : null,
65
+ // Pass-through bag for adapter-specific options (locale, theme, etc.)
66
+ options: p.options && typeof p.options === 'object' ? p.options : {}
67
+ };
68
+ return _cache;
69
+ }
70
+
71
+ window.ManifestPaymentsConfig = { getPaymentsConfig, ensureManifest };
72
+
73
+
74
+ /* Payments adapters */
75
+ //
76
+ // An adapter is the ONLY provider-specific client code. It knows how to open a
77
+ // provider's overlay/embedded checkout. Link-through (redirect) needs no adapter
78
+ // and is the universal floor — ANY provider with a hosted page works without one.
79
+ //
80
+ // Capability tiers:
81
+ // overlay (supportsOverlay: true) — provider exposes a programmatic modal
82
+ // with a success/cancel callback. Shipped
83
+ // for: revolut, paddle, lemonsqueezy,
84
+ // polar, razorpay.
85
+ // redirect (supportsOverlay: false) — no no-mount modal; works via the redirect
86
+ // floor (server returns {mode:'redirect',
87
+ // url}). Embedded/popup variants (Stripe
88
+ // Elements, Square Web Payments, PayPal
89
+ // Buttons, …) can be added per-provider via
90
+ // $pay.register(name, customAdapter).
91
+ //
92
+ // Adapter shape:
93
+ // { supportsOverlay: bool,
94
+ // async open({ url, params, config }) -> { status: 'complete'|'cancelled'|… } }
95
+ //
96
+ // The server (your function) decides modality per response, so adapters never see
97
+ // secret keys — only a hosted url, publishable params, or a public token.
98
+
99
+ // Load a CDN script once; resolve when ready. Adapters call this on first use so
100
+ // heavy provider SDKs are never bundled and never appear in prerendered output.
101
+ const _loaded = {};
102
+ function loadScript(src, attrs) {
103
+ if (_loaded[src]) return _loaded[src];
104
+ _loaded[src] = new Promise((resolve, reject) => {
105
+ const existing = document.querySelector(`script[src="${src}"]`);
106
+ if (existing) {
107
+ if (existing.dataset.mnfstLoaded) return resolve();
108
+ existing.addEventListener('load', () => resolve());
109
+ existing.addEventListener('error', () => reject(new Error(`Failed to load ${src}`)));
110
+ return;
111
+ }
112
+ const s = document.createElement('script');
113
+ s.src = src;
114
+ s.async = true;
115
+ if (attrs) for (const k in attrs) s.setAttribute(k, attrs[k]);
116
+ s.onload = () => { s.dataset.mnfstLoaded = '1'; resolve(); };
117
+ s.onerror = () => reject(new Error(`Failed to load ${src}`));
118
+ document.head.appendChild(s);
119
+ });
120
+ return _loaded[src];
121
+ }
122
+
123
+ const _registry = {};
124
+ function register(name, adapter) { _registry[name] = adapter; }
125
+ function get(name) { return name ? _registry[name] || null : null; }
126
+ function list() { return Object.keys(_registry).map(n => ({ name: n, overlay: !!_registry[n].supportsOverlay })); }
127
+
128
+ /* ---- Overlay adapters (programmatic modal + success callback) ------------- */
129
+
130
+ // Revolut Merchant — popup over an order token. Function returns
131
+ // { provider:'revolut', params:{ token } } (token = the order's public id).
132
+ register('revolut', {
133
+ supportsOverlay: true,
134
+ async open({ params = {}, config }) {
135
+ const env = (config?.environment === 'live') ? 'prod' : 'sandbox';
136
+ await loadScript(env === 'prod'
137
+ ? 'https://merchant.revolut.com/embed.js'
138
+ : 'https://sandbox-merchant.revolut.com/embed.js');
139
+ if (!window.RevolutCheckout) throw new Error('RevolutCheckout SDK unavailable');
140
+ const instance = await window.RevolutCheckout(params.token, env);
141
+ return new Promise((resolve) => {
142
+ instance.payWithPopup({
143
+ onSuccess: () => { instance.destroy?.(); resolve({ status: 'complete' }); },
144
+ onCancel: () => { instance.destroy?.(); resolve({ status: 'cancelled' }); },
145
+ onError: (e) => { instance.destroy?.(); resolve({ status: 'error', error: e?.message }); }
146
+ });
147
+ });
148
+ }
149
+ });
150
+
151
+ // Paddle (MoR) — overlay via Paddle.js. Function returns
152
+ // { provider:'paddle', params:{ token, items, customer, … } } (token = publishable).
153
+ register('paddle', {
154
+ supportsOverlay: true,
155
+ async open({ params = {}, config }) {
156
+ await loadScript('https://cdn.paddle.com/paddle/v2/paddle.js');
157
+ if (!window.Paddle) throw new Error('Paddle SDK unavailable');
158
+ if (config?.environment !== 'live') window.Paddle.Environment.set('sandbox');
159
+ window.Paddle.Initialize({ token: params.token || config?.publicKey });
160
+ return new Promise((resolve) => {
161
+ window.Paddle.Checkout.open({
162
+ ...params,
163
+ eventCallback: (ev) => {
164
+ if (ev?.name === 'checkout.completed') resolve({ status: 'complete' });
165
+ if (ev?.name === 'checkout.closed') resolve({ status: 'cancelled' });
166
+ }
167
+ });
168
+ });
169
+ }
170
+ });
171
+
172
+ // Lemon Squeezy (MoR) — URL-in-a-modal via lemon.js. Function returns
173
+ // { provider:'lemonsqueezy', url } (a hosted checkout URL).
174
+ register('lemonsqueezy', {
175
+ supportsOverlay: true,
176
+ async open({ url }) {
177
+ if (!url) throw new Error('Lemon Squeezy overlay requires a checkout url');
178
+ await loadScript('https://app.lemonsqueezy.com/js/lemon.js');
179
+ if (window.createLemonSqueezy) window.createLemonSqueezy();
180
+ return new Promise((resolve) => {
181
+ const ls = window.LemonSqueezy;
182
+ if (ls?.Setup) ls.Setup({ eventHandler: (e) => {
183
+ if (e?.event === 'Checkout.Success') resolve({ status: 'complete' });
184
+ if (e?.event === 'Checkout.Closed') resolve({ status: 'cancelled' });
185
+ }});
186
+ if (ls?.Url?.Open) ls.Url.Open(url);
187
+ else { window.location.assign(url); resolve({ status: 'redirected' }); }
188
+ });
189
+ }
190
+ });
191
+
192
+ // Polar (MoR) — URL-in-a-modal via the embed SDK. Function returns
193
+ // { provider:'polar', url } (a checkout-link/session url).
194
+ register('polar', {
195
+ supportsOverlay: true,
196
+ async open({ url }) {
197
+ if (!url) throw new Error('Polar overlay requires a checkout url');
198
+ await loadScript('https://cdn.jsdelivr.net/npm/@polar-sh/checkout@0.3/dist/embed.global.js');
199
+ const Embed = window.Polar?.EmbedCheckout;
200
+ if (!Embed?.create) { window.location.assign(url); return { status: 'redirected' }; }
201
+ const checkout = await Embed.create(url);
202
+ return new Promise((resolve) => {
203
+ checkout.addEventListener('success', () => resolve({ status: 'complete' }));
204
+ checkout.addEventListener('close', () => resolve({ status: 'cancelled' }));
205
+ });
206
+ }
207
+ });
208
+
209
+ // Razorpay — true modal checkout. Function returns
210
+ // { provider:'razorpay', params:{ key, order_id, amount, … } }.
211
+ register('razorpay', {
212
+ supportsOverlay: true,
213
+ async open({ params = {} }) {
214
+ await loadScript('https://checkout.razorpay.com/v1/checkout.js');
215
+ if (!window.Razorpay) throw new Error('Razorpay SDK unavailable');
216
+ return new Promise((resolve) => {
217
+ const rzp = new window.Razorpay({
218
+ ...params,
219
+ handler: () => resolve({ status: 'complete' }),
220
+ modal: { ...(params.modal || {}), ondismiss: () => resolve({ status: 'cancelled' }) }
221
+ });
222
+ rzp.open();
223
+ });
224
+ }
225
+ });
226
+
227
+ /* ---- Redirect-floor providers --------------------------------------------- */
228
+ // Well-known hosted-page providers with no clean no-mount modal. They work today
229
+ // via the redirect floor — the function returns { mode:'redirect', url } (a hosted
230
+ // checkout / payment link / order approve url). Registered for discoverability and
231
+ // so a mistaken mode:'overlay' degrades cleanly. Add an embedded/popup variant any
232
+ // time with $pay.register(name, { supportsOverlay:true, open }).
233
+ //
234
+ // stripe Checkout session url (Elements/embedded = custom adapter)
235
+ // square Payment Link / Checkout API url (Web Payments SDK = custom)
236
+ // paypal Orders API approve url (Smart Buttons popup = custom)
237
+ // braintree hosted / Drop-in (Drop-in needs a mount node = custom)
238
+ // adyen Pay-by-Link / hosted (Drop-in/Components = custom)
239
+ // mollie hosted checkout (redirect-first by design)
240
+ //
241
+ // NOTE: any provider NOT listed here still works with zero config via the same
242
+ // redirect floor (absolute-URL ref, or the function returning {mode:'redirect'}).
243
+ // Hosted destinations like Patreon / Buy Me a Coffee / Ko-fi / donation pages are
244
+ // just link-through: <a x-pay="'https://patreon.com/you'">…</a>. No adapter needed.
245
+ ['stripe', 'square', 'paypal', 'braintree', 'adyen', 'mollie']
246
+ .forEach((name) => register(name, { supportsOverlay: false, redirectOnly: true }));
247
+
248
+ window.ManifestPaymentsAdapters = { register, get, list, loadScript };
249
+
250
+
251
+ /* Payments store */
252
+ //
253
+ // Reactive in-flight + server-defined state for $pay. Commerce semantics are NOT
254
+ // modelled here — `state` is whatever your function returns (schema-less). The
255
+ // core engine (plain JS) mutates these fields via Alpine.store('pay').
256
+
257
+ function initializePaymentsStore() {
258
+ if (typeof Alpine === 'undefined') return;
259
+ if (window.__manifestPaymentsStoreInitialized) return;
260
+ window.__manifestPaymentsStoreInitialized = true;
261
+
262
+ Alpine.store('pay', {
263
+ loading: false, // a checkout/portal flow is in flight
264
+ error: null, // last error message
265
+ last: null, // last raw function response
266
+ state: null // server-defined entitlement record (managed mode / refreshState)
267
+ });
268
+ }
269
+
270
+ document.addEventListener('alpine:init', () => {
271
+ try { initializePaymentsStore(); } catch (_) { /* graceful */ }
272
+ });
273
+
274
+ window.ManifestPaymentsStore = { initialize: initializePaymentsStore };
275
+
276
+
277
+ /* Payments core */
278
+ //
279
+ // CONTRACT (client → your function):
280
+ // POST {endpoint} { ref, payload, context }
281
+ // → { mode:'redirect', url } navigate to a hosted page
282
+ // → { mode:'overlay', provider, params } SDK-native overlay
283
+ // → { mode:'overlay', provider, url } URL-in-a-modal overlay
284
+ // GET {state.url}?workspace=…&user=… → arbitrary entitlement record
285
+ //
286
+ // `ref` is OPAQUE — a string only your function understands (price id, plan code,
287
+ // cart token, "portal", …). Manifest never interprets it. All commerce semantics,
288
+ // secret keys and webhook fulfilment live behind the function. Fulfilment is the
289
+ // webhook — never trust this redirect/callback; always reconcile against state.
290
+
291
+ const RETURN_PARAM = 'checkout';
292
+
293
+ function store() { return window.Alpine?.store('pay') || null; }
294
+ function setStore(patch) { const s = store(); if (s) Object.assign(s, patch); }
295
+
296
+ // Navigation indirection. Defaults to a full-page redirect; override via
297
+ // setNavigate() to intercept (SPA router integration, or test harnesses that
298
+ // must not actually leave the page).
299
+ let _navigate = (url) => window.location.assign(url);
300
+ function setNavigate(fn) { if (typeof fn === 'function') _navigate = fn; }
301
+
302
+ // Identity context, auto-injected so the function knows who/which workspace pays.
303
+ // Read from the auth store if the auth plugin is present; absent otherwise.
304
+ function getContext() {
305
+ const ctx = {};
306
+ try {
307
+ const auth = window.Alpine?.store('auth');
308
+ if (auth) {
309
+ if (auth.user?.$id) ctx.userId = auth.user.$id;
310
+ if (auth.currentTeam?.$id) ctx.workspaceId = auth.currentTeam.$id;
311
+ }
312
+ } catch (_) { /* auth optional */ }
313
+ return ctx;
314
+ }
315
+
316
+ async function postSession(config, ref, payload, preferMode) {
317
+ const context = { ...getContext(), ...(payload?.context || {}) };
318
+ if (preferMode) context.preferMode = preferMode;
319
+ const res = await fetch(config.endpoint, {
320
+ method: 'POST',
321
+ headers: { 'Content-Type': 'application/json' },
322
+ credentials: 'include',
323
+ body: JSON.stringify({ ref, payload: payload || null, context })
324
+ });
325
+ if (!res.ok) throw new Error(`Payments endpoint ${res.status}`);
326
+ return res.json();
327
+ }
328
+
329
+ // Drive a server response into the right modality. Overlay degrades to redirect
330
+ // when no adapter supports it — the universal floor, so no author hits a wall.
331
+ async function dispatch(response, config) {
332
+ const mode = response?.mode || 'redirect';
333
+ if (mode === 'overlay') {
334
+ const provider = response.provider || config.provider;
335
+ const adapter = window.ManifestPaymentsAdapters.get(provider);
336
+ if (adapter?.supportsOverlay && typeof adapter.open === 'function') {
337
+ const result = await adapter.open({ url: response.url, params: response.params, config });
338
+ if (result?.status === 'complete') await refreshState();
339
+ window.dispatchEvent(new CustomEvent('manifest:pay:result', { detail: { ...result, provider } }));
340
+ return result;
341
+ }
342
+ if (response.url) { _navigate(response.url); return { status: 'redirected' }; }
343
+ throw new Error(`No overlay adapter for "${provider}" and no fallback url`);
344
+ }
345
+ if (!response?.url) throw new Error('Redirect response missing url');
346
+ _navigate(response.url);
347
+ return { status: 'redirected' };
348
+ }
349
+
350
+ // Public: initiate a payment flow for an opaque ref.
351
+ // ref string the function understands, OR an absolute URL for a plain
352
+ // link-through with no server (the "embed a checkout link today" case).
353
+ // payload optional opaque data forwarded to the function (+ optional .context).
354
+ async function initiate(ref, payload = {}) {
355
+ setStore({ loading: true, error: null });
356
+ try {
357
+ // Zero-server link-through: an absolute URL just navigates.
358
+ if (typeof ref === 'string' && /^https?:\/\//i.test(ref)) {
359
+ _navigate(ref);
360
+ return { status: 'redirected' };
361
+ }
362
+ const config = await window.ManifestPaymentsConfig.getPaymentsConfig();
363
+ if (!config) throw new Error('No "payments" config in manifest.json');
364
+ if (!config.endpoint) throw new Error('payments.endpoint is required for server-backed refs');
365
+
366
+ const preferMode = payload.mode || config.mode;
367
+ const response = await postSession(config, ref, payload, preferMode);
368
+ setStore({ last: response });
369
+ return await dispatch(response, config);
370
+ } catch (err) {
371
+ setStore({ error: err.message || String(err) });
372
+ window.dispatchEvent(new CustomEvent('manifest:pay:error', { detail: { ref, error: err.message } }));
373
+ throw err;
374
+ } finally {
375
+ setStore({ loading: false });
376
+ }
377
+ }
378
+
379
+ // Convenience: open the billing/customer portal. Just another ref ("portal" by
380
+ // default) the function maps to a server-minted portal session.
381
+ function portal(ref = 'portal', payload = {}) { return initiate(ref, payload); }
382
+
383
+ // Read server-defined entitlement state into $pay.state (managed mode). Appwrite
384
+ // projects should read state reactively via $x instead of configuring state.url.
385
+ async function refreshState() {
386
+ const config = await window.ManifestPaymentsConfig.getPaymentsConfig();
387
+ if (!config?.state?.url) return null;
388
+ try {
389
+ const ctx = getContext();
390
+ const q = new URLSearchParams();
391
+ if (ctx.workspaceId) q.set('workspace', ctx.workspaceId);
392
+ if (ctx.userId) q.set('user', ctx.userId);
393
+ const url = config.state.url + (q.toString() ? `?${q}` : '');
394
+ const res = await fetch(url, { credentials: 'include' });
395
+ if (!res.ok) return null;
396
+ const data = await res.json();
397
+ setStore({ state: data });
398
+ return data;
399
+ } catch (_) {
400
+ return null;
401
+ }
402
+ }
403
+
404
+ // On return from a redirect checkout, settle: the webhook is the source of truth,
405
+ // so we re-pull state rather than trusting the success redirect. Strips our marker
406
+ // from the URL afterward. Success pages should carry ?checkout=1 (set as the
407
+ // function's success_url) so we know to reconcile.
408
+ //
409
+ // The provider's webhook can land seconds AFTER the buyer does, so a single
410
+ // refresh can read pre-payment state. Re-poll on a short backoff to absorb the
411
+ // lag — each refresh overwrites $pay.state, so the page settles on the granted
412
+ // record without author code.
413
+ const RETURN_POLL_MS = [2000, 5000, 10000];
414
+ function handleReturn() {
415
+ try {
416
+ const params = new URLSearchParams(window.location.search);
417
+ if (!params.has(RETURN_PARAM)) return;
418
+ const status = params.get(RETURN_PARAM);
419
+ refreshState();
420
+ RETURN_POLL_MS.forEach((ms) => setTimeout(() => { refreshState(); }, ms));
421
+ window.dispatchEvent(new CustomEvent('manifest:pay:return', { detail: { status } }));
422
+ params.delete(RETURN_PARAM);
423
+ const qs = params.toString();
424
+ const clean = window.location.pathname + (qs ? `?${qs}` : '') + window.location.hash;
425
+ window.history.replaceState({}, '', clean);
426
+ } catch (_) { /* graceful */ }
427
+ }
428
+
429
+ window.ManifestPayments = { initiate, portal, refreshState, handleReturn, getContext, setNavigate };
430
+
431
+
432
+ /* Payments magic ($pay) */
433
+ //
434
+ // $pay is a callable magic with reactive properties:
435
+ // $pay('pro-monthly') initiate a flow for an opaque ref → Promise
436
+ // $pay('https://buy.…') absolute URL → plain link-through, no server
437
+ // $pay.portal() open the billing/customer portal
438
+ // $pay.refresh() re-pull server state into $pay.state
439
+ // $pay.register(name, adapter) add/override an overlay adapter (escape hatch)
440
+ // $pay.state server-defined entitlement record (schema-less)
441
+ // $pay.loading / $pay.error / $pay.last
442
+ //
443
+ // Reads of .state/.loading/etc. go through the reactive 'pay' store, so Alpine
444
+ // tracks them in x-show / x-text. State for Appwrite projects is read via $x.
445
+
446
+ function initializePaymentsMagic() {
447
+ if (typeof Alpine === 'undefined') return;
448
+ if (window.__manifestPaymentsMagicInitialized) return;
449
+ window.__manifestPaymentsMagicInitialized = true;
450
+
451
+ Alpine.magic('pay', () => {
452
+ const api = window.ManifestPayments;
453
+ const fn = (ref, payload) => api.initiate(ref, payload);
454
+ fn.portal = (ref, payload) => api.portal(ref, payload);
455
+ fn.refresh = () => api.refreshState();
456
+ fn.register = (name, adapter) => window.ManifestPaymentsAdapters.register(name, adapter);
457
+ Object.defineProperties(fn, {
458
+ state: { get: () => Alpine.store('pay')?.state },
459
+ loading: { get: () => Alpine.store('pay')?.loading },
460
+ error: { get: () => Alpine.store('pay')?.error },
461
+ last: { get: () => Alpine.store('pay')?.last }
462
+ });
463
+ return fn;
464
+ });
465
+ }
466
+
467
+ document.addEventListener('alpine:init', () => {
468
+ try { initializePaymentsMagic(); } catch (_) { /* graceful */ }
469
+ });
470
+
471
+ window.ManifestPaymentsMagic = { initialize: initializePaymentsMagic };
472
+
473
+
474
+ /* Payments directive (x-pay) */
475
+ //
476
+ // Sugar for "on click, initiate this ref". Markup is provider- AND modality-
477
+ // agnostic — switching either is a config/server change, never an HTML change.
478
+ // <button x-pay="'pro-monthly'">Subscribe</button>
479
+ // <button x-pay="'credits-1000'">Buy credits</button>
480
+ // <button x-pay.portal>Manage billing</button>
481
+ // <button x-pay.overlay="'pro-monthly'">Subscribe</button> modality hint
482
+ // <a x-pay="cart.token">Checkout</a>
483
+ //
484
+ // Modifiers:
485
+ // .portal treat as a portal flow (ref defaults to "portal")
486
+ // .overlay/.redirect hint preferred modality to the function (server decides)
487
+
488
+ function initializePaymentsDirective() {
489
+ if (typeof Alpine === 'undefined') return;
490
+ if (window.__manifestPaymentsDirectiveInitialized) return;
491
+ window.__manifestPaymentsDirectiveInitialized = true;
492
+
493
+ Alpine.directive('pay', (el, { expression, modifiers }, { evaluateLater, cleanup }) => {
494
+ const isPortal = modifiers.includes('portal');
495
+ const modeHint = modifiers.includes('overlay') ? 'overlay'
496
+ : modifiers.includes('redirect') ? 'redirect' : null;
497
+
498
+ const hasExpr = expression && expression.trim().length > 0;
499
+ const getRef = hasExpr ? evaluateLater(expression) : null;
500
+
501
+ // Busy guard: ignore clicks while a flow is in flight and disable the
502
+ // element, so double-clicks can't mint duplicate checkout sessions.
503
+ // On failure, surface feedback automatically via the toasts plugin
504
+ // when it's loaded ($toast resolved through this element's Alpine
505
+ // scope); programmatic $pay() callers handle their own rejections.
506
+ const run = async (ref) => {
507
+ if (el.getAttribute('aria-busy') === 'true') return;
508
+ const payload = modeHint ? { mode: modeHint } : {};
509
+ const api = window.ManifestPayments;
510
+ if (!api) { console.warn('[x-pay] payments core not loaded'); return; }
511
+ if (!isPortal && (ref === undefined || ref === null || ref === '')) {
512
+ console.warn('[x-pay] no ref provided'); return;
513
+ }
514
+ el.setAttribute('aria-busy', 'true');
515
+ const hadDisabled = 'disabled' in el ? el.disabled : null;
516
+ if (hadDisabled === false) el.disabled = true;
517
+ try {
518
+ if (isPortal) await api.portal(typeof ref === 'string' && ref ? ref : 'portal', payload);
519
+ else await api.initiate(ref, payload);
520
+ } catch (err) {
521
+ try {
522
+ const toast = Alpine.evaluate(el, '$toast');
523
+ if (toast?.negative) toast.negative(err?.message || 'Payment could not be started');
524
+ } catch (_) { /* toasts plugin not loaded */ }
525
+ } finally {
526
+ el.removeAttribute('aria-busy');
527
+ if (hadDisabled === false) el.disabled = false;
528
+ }
529
+ };
530
+
531
+ const handler = (e) => {
532
+ e.preventDefault();
533
+ if (getRef) getRef((ref) => run(ref));
534
+ else run('portal'); // bare x-pay.portal
535
+ };
536
+
537
+ el.addEventListener('click', handler);
538
+ cleanup(() => el.removeEventListener('click', handler));
539
+ });
540
+ }
541
+
542
+ document.addEventListener('alpine:init', () => {
543
+ try { initializePaymentsDirective(); } catch (_) { /* graceful */ }
544
+ });
545
+
546
+ window.ManifestPaymentsDirective = { initialize: initializePaymentsDirective };
547
+
548
+
549
+ /* Payments main */
550
+ //
551
+ // Orchestration: settle redirect-returns and pull initial state once Alpine and
552
+ // (optionally) auth are ready. Registration of store/magic/directive happens in
553
+ // their own subscripts on alpine:init.
554
+
555
+ function bootPayments() {
556
+ if (window.__manifestPaymentsBooted) return;
557
+ window.__manifestPaymentsBooted = true;
558
+
559
+ // Settle any redirect-return immediately (webhook is truth — this re-pulls state).
560
+ try { window.ManifestPayments?.handleReturn(); } catch (_) { /* graceful */ }
561
+
562
+ // Initial managed-mode state pull. If auth is present, wait for it so the
563
+ // identity context (user/workspace) is populated before the request.
564
+ const pull = () => { try { window.ManifestPayments?.refreshState(); } catch (_) {} };
565
+ if (window.Alpine?.store && Alpine.store('auth')) {
566
+ window.addEventListener('manifest:auth:initialized', pull, { once: true });
567
+ // Fallback in case auth already initialized before this listener attached.
568
+ setTimeout(pull, 1500);
569
+ } else {
570
+ pull();
571
+ }
572
+ }
573
+
574
+ document.addEventListener('alpine:init', () => {
575
+ try { bootPayments(); } catch (_) { /* graceful */ }
576
+ });
577
+
578
+ // Cover the case where Alpine already initialized before this script ran.
579
+ if (typeof Alpine !== 'undefined') {
580
+ try { bootPayments(); } catch (_) { /* graceful */ }
581
+ }
582
+
583
+ window.ManifestPaymentsMain = { boot: bootPayments };
@@ -193,6 +193,51 @@
193
193
  }
194
194
  }
195
195
  },
196
+ "status": {
197
+ "type": "object",
198
+ "description": "Named, top-level-addressable status signals exposed via $status.<name>. Each entry rolls up one or more underlying signals into a single health state. Signal layer only — the author renders their own UI.",
199
+ "additionalProperties": {
200
+ "oneOf": [
201
+ {
202
+ "type": "string",
203
+ "description": "Shorthand: an HTTP URL probed for reachability/latency."
204
+ },
205
+ {
206
+ "type": "array",
207
+ "description": "Multiple signals rolled up (worst-of by default).",
208
+ "items": { "$ref": "#/definitions/statusSignal" }
209
+ },
210
+ {
211
+ "type": "object",
212
+ "description": "A single signal, or an aggregate via `signals` with rollup options.",
213
+ "properties": {
214
+ "signals": {
215
+ "type": "array",
216
+ "items": { "$ref": "#/definitions/statusSignal" }
217
+ },
218
+ "rollup": {
219
+ "type": "string",
220
+ "enum": ["worst", "best"],
221
+ "default": "worst"
222
+ },
223
+ "refresh": {
224
+ "type": "number",
225
+ "description": "Poll interval in milliseconds (default 30000)."
226
+ },
227
+ "confirmations": {
228
+ "type": "number",
229
+ "description": "Consecutive observations required before a state change (hysteresis)."
230
+ },
231
+ "staleAfter": {
232
+ "type": "number",
233
+ "description": "Milliseconds without a known signal before the entry reads as stale (default refresh × 3)."
234
+ }
235
+ },
236
+ "additionalProperties": true
237
+ }
238
+ ]
239
+ }
240
+ },
196
241
  "render": {
197
242
  "type": "object",
198
243
  "description": "Static rendering configuration consumed by mnfst-render.",
@@ -208,5 +253,37 @@
208
253
  },
209
254
  "additionalProperties": true
210
255
  }
256
+ },
257
+ "definitions": {
258
+ "statusSignal": {
259
+ "description": "One health input. Provider is inferred by which field is present.",
260
+ "oneOf": [
261
+ {
262
+ "type": "string",
263
+ "description": "An HTTP URL probed for reachability/latency."
264
+ },
265
+ {
266
+ "type": "object",
267
+ "additionalProperties": true,
268
+ "properties": {
269
+ "label": { "type": "string" },
270
+ "url": { "type": "string", "description": "HTTP probe target." },
271
+ "expect": { "type": "number", "description": "Exact status code to treat as operational (default: any 2xx)." },
272
+ "degradedAbove": { "type": "number", "description": "Latency in ms above which the probe reads as degraded." },
273
+ "method": { "type": "string" },
274
+ "headers": { "type": "object", "additionalProperties": { "type": "string" } },
275
+ "timeout": { "type": "number" },
276
+ "static": { "type": "string", "description": "Literal state (operational | degraded | partial_outage | major_outage | maintenance)." },
277
+ "feed": { "type": "string", "description": "URL returning JSON whose state field is read." },
278
+ "path": { "type": "string", "description": "Dot-path into a feed response to the state value." },
279
+ "mirror": { "type": "string", "description": "Named upstream whose own status feed is reflected (e.g. 'appwrite')." },
280
+ "appwriteService": { "type": "string", "description": "Resolves against the global appwrite endpoint's /health." },
281
+ "mcp": { "type": "string", "description": "Hosted (Manifest MCP) signal; a feed URL when self-resolving." },
282
+ "heartbeat": { "type": "string", "description": "Dead-man's-switch key; call $status.beat(key) to pulse." },
283
+ "expectEvery": { "type": "number", "description": "Heartbeat window in ms before it reads as down." }
284
+ }
285
+ }
286
+ ]
287
+ }
211
288
  }
212
289
  }