mnfst 0.5.121 → 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.
- package/lib/manifest.chart.css +195 -0
- package/lib/manifest.charts.js +593 -0
- package/lib/manifest.checkbox.css +2 -2
- package/lib/manifest.colorpicker.js +198 -41
- package/lib/manifest.css +755 -21
- package/lib/manifest.data.js +35 -7
- package/lib/manifest.datepicker.css +504 -0
- package/lib/manifest.datepicker.js +1208 -0
- package/lib/manifest.dialog.css +7 -4
- package/lib/manifest.dropdown.css +7 -10
- package/lib/manifest.integrity.json +9 -5
- package/lib/manifest.js +18 -4
- package/lib/manifest.localization.js +5 -1
- package/lib/manifest.min.css +1 -1
- package/lib/manifest.payments.js +583 -0
- package/lib/manifest.schema.json +77 -0
- package/lib/manifest.sidebar.css +7 -6
- package/lib/manifest.status.js +680 -0
- package/lib/manifest.theme.css +6 -4
- package/lib/manifest.toast.css +1 -1
- package/lib/manifest.tooltip.css +48 -16
- package/lib/manifest.utilities.css +3 -2
- package/lib/manifest.utilities.js +18 -2
- package/package.json +3 -1
|
@@ -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 };
|
package/lib/manifest.schema.json
CHANGED
|
@@ -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
|
}
|