@sweepconsole/js 0.1.0 → 0.2.1
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/README.md +61 -2
- package/dist/index.d.ts +49 -0
- package/dist/index.js +206 -1
- package/dist/sweep.js +206 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ A **Subscribe with USDC** button for your checkout. It asks your server for a UR
|
|
|
5
5
|
~3KB gzipped, no dependencies.
|
|
6
6
|
|
|
7
7
|
```html
|
|
8
|
-
<script src="https://cdn.jsdelivr.net/npm/@sweepconsole/js@0.1
|
|
8
|
+
<script src="https://cdn.jsdelivr.net/npm/@sweepconsole/js@0.2.1/dist/sweep.js"></script>
|
|
9
9
|
<button id="pay-usdc">Subscribe with USDC</button>
|
|
10
10
|
|
|
11
11
|
<script>
|
|
@@ -43,6 +43,65 @@ app.post("/api/sweep/session", requireLogin, async (req, res) => {
|
|
|
43
43
|
That is the whole integration. The payer signs in their wallet on Sweep's hosted
|
|
44
44
|
page, and `mandate.authorized` arrives on your webhook endpoint.
|
|
45
45
|
|
|
46
|
+
## Keeping the payer on your page
|
|
47
|
+
|
|
48
|
+
`mount()` navigates away and comes back by `return_url`. On a subscription page
|
|
49
|
+
that is the wrong shape: the payer is mid-task in an account they are already
|
|
50
|
+
signed into, and a full navigation costs them their scroll position, any form
|
|
51
|
+
state, and the sense of still being on your site.
|
|
52
|
+
|
|
53
|
+
`authorize()` opens the same checkout in a panel over your page instead.
|
|
54
|
+
|
|
55
|
+
```html
|
|
56
|
+
<button id="authorize">Authorize payments</button>
|
|
57
|
+
<script>
|
|
58
|
+
Sweep.authorize("#authorize", {
|
|
59
|
+
session: "/api/sweep/mandate-link",
|
|
60
|
+
onAuthorized: (m) => location.assign("/billing?ok=" + m.mandate_id),
|
|
61
|
+
onCancelled: () => console.log("payer closed it"),
|
|
62
|
+
});
|
|
63
|
+
</script>
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
A centred 520px panel on a desktop, a full-height sheet under 560px. Escape,
|
|
67
|
+
the close button and a backdrop click all dismiss it and fire `onCancelled`.
|
|
68
|
+
Your page's scroll position and the focused element are restored on the way
|
|
69
|
+
out, which is the entire reason for not navigating.
|
|
70
|
+
|
|
71
|
+
`mode` picks how it opens — `"modal"` (default), `"popup"` for a separate
|
|
72
|
+
window, or `"redirect"` for exactly what `mount()` does.
|
|
73
|
+
|
|
74
|
+
### Wallets and frames
|
|
75
|
+
|
|
76
|
+
A browser extension is not obliged to inject itself into a cross-origin
|
|
77
|
+
iframe, and this checkout needs a wallet signature. Where the extension has
|
|
78
|
+
not, the framed page opens its own window for the signing step — a top-level
|
|
79
|
+
context where every wallet behaves normally — and reports the result back
|
|
80
|
+
through the frame. You do not configure this and your `onAuthorized` fires the
|
|
81
|
+
same either way.
|
|
82
|
+
|
|
83
|
+
It is why the panel is not sandboxed: sandboxing without `allow-popups` would
|
|
84
|
+
close the one exit that page has.
|
|
85
|
+
|
|
86
|
+
### React
|
|
87
|
+
|
|
88
|
+
```tsx
|
|
89
|
+
import { SweepAuthorize } from "@sweepconsole/react";
|
|
90
|
+
|
|
91
|
+
<SweepAuthorize session="/api/sweep/mandate-link" onAuthorized={(m) => save(m.mandate_id)}>
|
|
92
|
+
Authorize with wallet
|
|
93
|
+
</SweepAuthorize>
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
A thin wrapper over `authorize()` — everything real lives here, so nothing is
|
|
97
|
+
missing for anyone loading this package from a script tag.
|
|
98
|
+
|
|
99
|
+
### Trying it
|
|
100
|
+
|
|
101
|
+
`examples/embed.html` is a stand-in for your page. Serve the package over HTTP
|
|
102
|
+
(`npx serve packages/js`, then `/examples/embed.html`) — `file://` has no
|
|
103
|
+
origin, and the checkout checks it.
|
|
104
|
+
|
|
46
105
|
## Why it will not call Sweep for you
|
|
47
106
|
|
|
48
107
|
This package has no API key and cannot create a session. Everything a browser
|
|
@@ -55,7 +114,7 @@ For a public link — a pricing page, an invoice, a QR code — there is no iden
|
|
|
55
114
|
to protect, and a plain anchor is enough:
|
|
56
115
|
|
|
57
116
|
```html
|
|
58
|
-
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@sweepconsole/js@0.1
|
|
117
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@sweepconsole/js@0.2.1/dist/button.css">
|
|
59
118
|
<a class="sweep-pay" href="https://pay.sweepconsole.com/pay/plink_a1b2c3?ref=user_42">
|
|
60
119
|
Subscribe with USDC
|
|
61
120
|
</a>
|
package/dist/index.d.ts
CHANGED
|
@@ -93,8 +93,57 @@ export declare const BUTTON_CSS = ".sweep-pay{--_bg:var(--sweep-bg,#201e1d);--_f
|
|
|
93
93
|
* never a styled div, which cannot be reached by keyboard.
|
|
94
94
|
*/
|
|
95
95
|
export declare function mount(target: string | Element, opts: MountOptions): SweepButton;
|
|
96
|
+
export declare const MODAL_CSS = ".sweep-modal{position:fixed;inset:0;z-index:2147483646;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(35,35,59,.55);-webkit-backdrop-filter:blur(3px);backdrop-filter:blur(3px);opacity:0;transition:opacity .2s ease}\n.sweep-modal[data-open=true]{opacity:1}\n.sweep-modal__panel{position:relative;width:100%;max-width:600px;height:min(680px,calc(100vh - 48px));background:#fff;border-radius:14px;overflow:hidden;box-shadow:0 0 0 1px rgba(35,35,80,.08),0 30px 60px -30px rgba(35,35,80,.45);opacity:0;transform:translateY(16px) scale(.92);transition:transform .34s cubic-bezier(.2,1.25,.4,1),opacity .2s ease}\n.sweep-modal[data-open=true] .sweep-modal__panel{opacity:1;transform:none}\n.sweep-modal__frame{display:block;width:100%;height:100%;border:0}\n.sweep-modal__close{position:absolute;top:10px;right:10px;width:30px;height:30px;display:flex;align-items:center;justify-content:center;border:0;border-radius:8px;background:rgba(244,244,251,.9);color:#5D5E73;cursor:pointer;font:inherit;font-size:17px;line-height:1}\n.sweep-modal__close:hover{background:#ECECF3;color:#23233B}\n.sweep-modal__close:focus-visible{outline:2px solid #4447D6;outline-offset:2px}\n@media (max-width:648px){.sweep-modal{padding:0}.sweep-modal__panel{max-width:none;height:100%;border-radius:0;opacity:1;transform:translateY(100%);transition:transform .32s cubic-bezier(.2,.8,.2,1)}.sweep-modal__close{top:8px;right:8px}}\n@media (prefers-reduced-motion:reduce){.sweep-modal,.sweep-modal__panel{transition:none}.sweep-modal__panel{transform:none}}";
|
|
97
|
+
export interface AuthorizeResult {
|
|
98
|
+
/** The mandate the payer signed, as the authorize page reported it. */
|
|
99
|
+
mandate_id?: string;
|
|
100
|
+
[key: string]: unknown;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* What opening the panel needs — no `session`, because the caller already has
|
|
104
|
+
* the URL. Split from AuthorizeOptions so `openAuthorize(url)` can be called
|
|
105
|
+
* with nothing else, which is the shape anyone driving it by hand wants.
|
|
106
|
+
*/
|
|
107
|
+
export interface OpenAuthorizeOptions {
|
|
108
|
+
/** Called once the payer has signed. The modal closes first. */
|
|
109
|
+
onAuthorized?: (result: AuthorizeResult) => void;
|
|
110
|
+
/** Called if they closed it without signing — backdrop, Escape or the X. */
|
|
111
|
+
onCancelled?: () => void;
|
|
112
|
+
/** Set false if your Content-Security-Policy forbids inline styles. */
|
|
113
|
+
injectStyles?: boolean;
|
|
114
|
+
}
|
|
115
|
+
export interface AuthorizeOptions extends MountOptions, OpenAuthorizeOptions {
|
|
116
|
+
/**
|
|
117
|
+
* Open in a new window instead of a panel. Worth setting when you already
|
|
118
|
+
* know your payers' wallets refuse to inject into frames; otherwise leave
|
|
119
|
+
* it and let the page fall back on its own.
|
|
120
|
+
*/
|
|
121
|
+
mode?: "modal" | "popup" | "redirect";
|
|
122
|
+
}
|
|
123
|
+
export interface SweepModal {
|
|
124
|
+
/** Close it as though the payer had dismissed it. */
|
|
125
|
+
close(): void;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Open the authorize page over the current one.
|
|
129
|
+
*
|
|
130
|
+
* Returns immediately — the callbacks carry the outcome.
|
|
131
|
+
*/
|
|
132
|
+
export declare function openAuthorize(url: string, opts?: OpenAuthorizeOptions): SweepModal;
|
|
133
|
+
/**
|
|
134
|
+
* A button that opens the authorize modal.
|
|
135
|
+
*
|
|
136
|
+
* Same contract as `mount()` — your endpoint returns a URL — except the payer
|
|
137
|
+
* stays on your page. `mode: "redirect"` makes it behave exactly like
|
|
138
|
+
* `mount()`, which is the escape hatch if a modal turns out to be wrong for
|
|
139
|
+
* your flow.
|
|
140
|
+
*/
|
|
141
|
+
export declare function authorize(target: string | Element, opts: AuthorizeOptions): SweepButton;
|
|
96
142
|
/** The global the CDN build defines. Same functions, one name to reach them. */
|
|
97
143
|
export declare const Sweep: {
|
|
98
144
|
mount: typeof mount;
|
|
145
|
+
authorize: typeof authorize;
|
|
146
|
+
openAuthorize: typeof openAuthorize;
|
|
99
147
|
BUTTON_CSS: string;
|
|
148
|
+
MODAL_CSS: string;
|
|
100
149
|
};
|
package/dist/index.js
CHANGED
|
@@ -251,5 +251,210 @@ export function mount(target, opts) {
|
|
|
251
251
|
},
|
|
252
252
|
};
|
|
253
253
|
}
|
|
254
|
+
/* ════════════════════════════════════════════════════════════════════════
|
|
255
|
+
The authorize modal — the rail checkout, over the merchant's own page.
|
|
256
|
+
════════════════════════════════════════════════════════════════════ */
|
|
257
|
+
/**
|
|
258
|
+
* Why a modal rather than a redirect.
|
|
259
|
+
*
|
|
260
|
+
* `mount()` sends the payer away and brings them back by `return_url`. That
|
|
261
|
+
* is fine for a one-off checkout and wrong for a subscription page, where the
|
|
262
|
+
* payer is mid-task in an account they are already signed into: a full
|
|
263
|
+
* navigation loses their scroll position, their form state, and any sense
|
|
264
|
+
* that they are still on the merchant's site.
|
|
265
|
+
*
|
|
266
|
+
* So `authorize()` opens the same page in a centered panel over a dimmed
|
|
267
|
+
* backdrop — desktop — or a full-height sheet on a phone, where a 520px card
|
|
268
|
+
* floating in the middle of a small screen is just a worse full page.
|
|
269
|
+
*
|
|
270
|
+
* WALLETS AND IFRAMES
|
|
271
|
+
*
|
|
272
|
+
* The authorize page asks for a wallet signature, and a browser extension is
|
|
273
|
+
* not obliged to inject itself into a cross-origin frame. When it has not,
|
|
274
|
+
* the framed page opens its own popup for the signing step — a top-level
|
|
275
|
+
* context where every wallet works. That handoff lives in the page rather
|
|
276
|
+
* than here, because only the page knows whether a provider answered. This
|
|
277
|
+
* side must simply not prevent it: the iframe carries no `sandbox`, because
|
|
278
|
+
* sandboxing without `allow-popups` would close the one exit the page has.
|
|
279
|
+
*/
|
|
280
|
+
const MODAL_STYLE_ID = "sweep-modal-styles";
|
|
281
|
+
/**
|
|
282
|
+
* Breakpoint below which the panel becomes a sheet.
|
|
283
|
+
*
|
|
284
|
+
* TIED TO THE PANEL WIDTH, not chosen separately. `.sweep-modal` keeps 24px of
|
|
285
|
+
* padding on each side, so a PANEL_MAX_WIDTH panel needs PANEL_MAX_WIDTH + 48
|
|
286
|
+
* of viewport before it starts being squeezed. Set the breakpoint any lower and
|
|
287
|
+
* there is a band of widths where the panel is neither its full size nor a
|
|
288
|
+
* sheet — it is a narrowed panel, which is the one shape nobody designed.
|
|
289
|
+
*
|
|
290
|
+
* That band existed: 520 + 48 = 568 against a 560 breakpoint left 561-568px
|
|
291
|
+
* squeezed. Small enough to never be noticed, and it would have grown to 88px
|
|
292
|
+
* wide the moment the panel did.
|
|
293
|
+
*/
|
|
294
|
+
const PANEL_MAX_WIDTH = 600;
|
|
295
|
+
const SHEET_MAX_WIDTH = PANEL_MAX_WIDTH + 48;
|
|
296
|
+
// The panel pops in like a dialog: it grows from 92% with a slight spring past
|
|
297
|
+
// full size while the backdrop dims and softens. On a phone it is a sheet that
|
|
298
|
+
// slides up from the bottom instead (see the media query).
|
|
299
|
+
export const MODAL_CSS = `.sweep-modal{position:fixed;inset:0;z-index:2147483646;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(35,35,59,.55);-webkit-backdrop-filter:blur(3px);backdrop-filter:blur(3px);opacity:0;transition:opacity .2s ease}
|
|
300
|
+
.sweep-modal[data-open=true]{opacity:1}
|
|
301
|
+
.sweep-modal__panel{position:relative;width:100%;max-width:${PANEL_MAX_WIDTH}px;height:min(680px,calc(100vh - 48px));background:#fff;border-radius:14px;overflow:hidden;box-shadow:0 0 0 1px rgba(35,35,80,.08),0 30px 60px -30px rgba(35,35,80,.45);opacity:0;transform:translateY(16px) scale(.92);transition:transform .34s cubic-bezier(.2,1.25,.4,1),opacity .2s ease}
|
|
302
|
+
.sweep-modal[data-open=true] .sweep-modal__panel{opacity:1;transform:none}
|
|
303
|
+
.sweep-modal__frame{display:block;width:100%;height:100%;border:0}
|
|
304
|
+
.sweep-modal__close{position:absolute;top:10px;right:10px;width:30px;height:30px;display:flex;align-items:center;justify-content:center;border:0;border-radius:8px;background:rgba(244,244,251,.9);color:#5D5E73;cursor:pointer;font:inherit;font-size:17px;line-height:1}
|
|
305
|
+
.sweep-modal__close:hover{background:#ECECF3;color:#23233B}
|
|
306
|
+
.sweep-modal__close:focus-visible{outline:2px solid #4447D6;outline-offset:2px}
|
|
307
|
+
@media (max-width:${SHEET_MAX_WIDTH}px){.sweep-modal{padding:0}.sweep-modal__panel{max-width:none;height:100%;border-radius:0;opacity:1;transform:translateY(100%);transition:transform .32s cubic-bezier(.2,.8,.2,1)}.sweep-modal__close{top:8px;right:8px}}
|
|
308
|
+
@media (prefers-reduced-motion:reduce){.sweep-modal,.sweep-modal__panel{transition:none}.sweep-modal__panel{transform:none}}`;
|
|
309
|
+
/**
|
|
310
|
+
* Open the authorize page over the current one.
|
|
311
|
+
*
|
|
312
|
+
* Returns immediately — the callbacks carry the outcome.
|
|
313
|
+
*/
|
|
314
|
+
export function openAuthorize(url, opts = {}) {
|
|
315
|
+
if (opts.injectStyles !== false)
|
|
316
|
+
injectModalStyles();
|
|
317
|
+
let frameOrigin;
|
|
318
|
+
try {
|
|
319
|
+
frameOrigin = new URL(url, window.location.href).origin;
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
throw new Error(`Sweep.openAuthorize: ${JSON.stringify(url)} is not a URL.`);
|
|
323
|
+
}
|
|
324
|
+
const root = document.createElement("div");
|
|
325
|
+
root.className = "sweep-modal";
|
|
326
|
+
root.setAttribute("role", "dialog");
|
|
327
|
+
root.setAttribute("aria-modal", "true");
|
|
328
|
+
root.setAttribute("aria-label", "Authorize payments");
|
|
329
|
+
const panel = document.createElement("div");
|
|
330
|
+
panel.className = "sweep-modal__panel";
|
|
331
|
+
const frame = document.createElement("iframe");
|
|
332
|
+
frame.className = "sweep-modal__frame";
|
|
333
|
+
// Deliberately no `sandbox`: see the note above — the page's popup fallback
|
|
334
|
+
// is the thing that makes this safe to attempt at all.
|
|
335
|
+
frame.setAttribute("allow", "clipboard-write; publickey-credentials-get *");
|
|
336
|
+
frame.setAttribute("title", "Authorize payments");
|
|
337
|
+
// `embed=1` is what tells the page to render as a panel and to speak this
|
|
338
|
+
// protocol. Without it the page behaves exactly as it always has.
|
|
339
|
+
frame.src = url + (url.indexOf("?") === -1 ? "?" : "&") + "embed=1";
|
|
340
|
+
const close = document.createElement("button");
|
|
341
|
+
close.type = "button";
|
|
342
|
+
close.className = "sweep-modal__close";
|
|
343
|
+
close.setAttribute("aria-label", "Close");
|
|
344
|
+
close.textContent = "\u00d7";
|
|
345
|
+
panel.appendChild(frame);
|
|
346
|
+
panel.appendChild(close);
|
|
347
|
+
root.appendChild(panel);
|
|
348
|
+
// What the page was scrolled to, and what had focus, so dismissing puts the
|
|
349
|
+
// merchant's page back exactly as the payer left it. This is the entire
|
|
350
|
+
// reason for not navigating away.
|
|
351
|
+
const prevOverflow = document.body.style.overflow;
|
|
352
|
+
const prevFocus = document.activeElement;
|
|
353
|
+
document.body.style.overflow = "hidden";
|
|
354
|
+
document.body.appendChild(root);
|
|
355
|
+
// Next frame, so the opening transition has somewhere to travel from.
|
|
356
|
+
requestAnimationFrame(() => root.setAttribute("data-open", "true"));
|
|
357
|
+
let done = false;
|
|
358
|
+
function teardown() {
|
|
359
|
+
if (done)
|
|
360
|
+
return;
|
|
361
|
+
done = true;
|
|
362
|
+
window.removeEventListener("message", onMessage);
|
|
363
|
+
document.removeEventListener("keydown", onKey);
|
|
364
|
+
document.body.style.overflow = prevOverflow;
|
|
365
|
+
root.remove();
|
|
366
|
+
if (prevFocus && typeof prevFocus.focus === "function")
|
|
367
|
+
prevFocus.focus();
|
|
368
|
+
}
|
|
369
|
+
function dismiss() {
|
|
370
|
+
const wasDone = done;
|
|
371
|
+
teardown();
|
|
372
|
+
if (!wasDone && opts.onCancelled)
|
|
373
|
+
opts.onCancelled();
|
|
374
|
+
}
|
|
375
|
+
function onKey(e) {
|
|
376
|
+
if (e.key === "Escape")
|
|
377
|
+
dismiss();
|
|
378
|
+
}
|
|
379
|
+
function onMessage(e) {
|
|
380
|
+
// Only this frame's origin may drive this modal. Without the check any
|
|
381
|
+
// page with a handle on this window could fake an authorization, and the
|
|
382
|
+
// merchant's `onAuthorized` is where a subscription gets switched on.
|
|
383
|
+
if (e.origin !== frameOrigin)
|
|
384
|
+
return;
|
|
385
|
+
if (e.source !== frame.contentWindow)
|
|
386
|
+
return;
|
|
387
|
+
const msg = e.data;
|
|
388
|
+
if (!msg || msg.source !== "sweep")
|
|
389
|
+
return;
|
|
390
|
+
if (msg.type === "ready") {
|
|
391
|
+
// Reply to the exact origin that spoke, never "*": from here on the
|
|
392
|
+
// page knows who framed it and can address us specifically.
|
|
393
|
+
frame.contentWindow?.postMessage({ source: "sweep", type: "host" }, e.origin);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
if (msg.type === "authorized") {
|
|
397
|
+
teardown();
|
|
398
|
+
if (opts.onAuthorized)
|
|
399
|
+
opts.onAuthorized(msg.mandate ?? {});
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
if (msg.type === "cancelled") {
|
|
403
|
+
dismiss();
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
window.addEventListener("message", onMessage);
|
|
407
|
+
document.addEventListener("keydown", onKey);
|
|
408
|
+
close.addEventListener("click", dismiss);
|
|
409
|
+
root.addEventListener("click", (e) => {
|
|
410
|
+
// Backdrop only — a click that started inside the panel must not close it.
|
|
411
|
+
if (e.target === root)
|
|
412
|
+
dismiss();
|
|
413
|
+
});
|
|
414
|
+
return { close: dismiss };
|
|
415
|
+
}
|
|
416
|
+
function injectModalStyles() {
|
|
417
|
+
if (typeof document === "undefined")
|
|
418
|
+
return;
|
|
419
|
+
if (document.getElementById(MODAL_STYLE_ID))
|
|
420
|
+
return;
|
|
421
|
+
const tag = document.createElement("style");
|
|
422
|
+
tag.id = MODAL_STYLE_ID;
|
|
423
|
+
tag.textContent = MODAL_CSS;
|
|
424
|
+
document.head.appendChild(tag);
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* A button that opens the authorize modal.
|
|
428
|
+
*
|
|
429
|
+
* Same contract as `mount()` — your endpoint returns a URL — except the payer
|
|
430
|
+
* stays on your page. `mode: "redirect"` makes it behave exactly like
|
|
431
|
+
* `mount()`, which is the escape hatch if a modal turns out to be wrong for
|
|
432
|
+
* your flow.
|
|
433
|
+
*/
|
|
434
|
+
export function authorize(target, opts) {
|
|
435
|
+
if (!opts || !opts.session) {
|
|
436
|
+
throw new Error("Sweep.authorize: `session` is required — your endpoint that returns a URL.");
|
|
437
|
+
}
|
|
438
|
+
return mount(target, {
|
|
439
|
+
...opts,
|
|
440
|
+
label: opts.label ?? "Authorize payments",
|
|
441
|
+
onRedirect: (url) => {
|
|
442
|
+
// Read per click, not captured at mount: a caller that flips `mode`
|
|
443
|
+
// between renders — a settings toggle, a test harness — gets the mode
|
|
444
|
+
// it is showing rather than the one it started with.
|
|
445
|
+
const mode = opts.mode ?? "modal";
|
|
446
|
+
if (mode === "redirect")
|
|
447
|
+
return; // let mount() navigate
|
|
448
|
+
if (mode === "popup") {
|
|
449
|
+
// Opened inside the click, so it is a user gesture and survives
|
|
450
|
+
// popup blockers.
|
|
451
|
+
window.open(url, "sweep_authorize", "width=520,height=720");
|
|
452
|
+
return false;
|
|
453
|
+
}
|
|
454
|
+
openAuthorize(url, opts);
|
|
455
|
+
return false; // we handled it
|
|
456
|
+
},
|
|
457
|
+
});
|
|
458
|
+
}
|
|
254
459
|
/** The global the CDN build defines. Same functions, one name to reach them. */
|
|
255
|
-
export const Sweep = { mount, BUTTON_CSS };
|
|
460
|
+
export const Sweep = { mount, authorize, openAuthorize, BUTTON_CSS, MODAL_CSS };
|
package/dist/sweep.js
CHANGED
|
@@ -254,8 +254,213 @@
|
|
|
254
254
|
},
|
|
255
255
|
};
|
|
256
256
|
}
|
|
257
|
+
/* ════════════════════════════════════════════════════════════════════════
|
|
258
|
+
The authorize modal — the rail checkout, over the merchant's own page.
|
|
259
|
+
════════════════════════════════════════════════════════════════════ */
|
|
260
|
+
/**
|
|
261
|
+
* Why a modal rather than a redirect.
|
|
262
|
+
*
|
|
263
|
+
* `mount()` sends the payer away and brings them back by `return_url`. That
|
|
264
|
+
* is fine for a one-off checkout and wrong for a subscription page, where the
|
|
265
|
+
* payer is mid-task in an account they are already signed into: a full
|
|
266
|
+
* navigation loses their scroll position, their form state, and any sense
|
|
267
|
+
* that they are still on the merchant's site.
|
|
268
|
+
*
|
|
269
|
+
* So `authorize()` opens the same page in a centered panel over a dimmed
|
|
270
|
+
* backdrop — desktop — or a full-height sheet on a phone, where a 520px card
|
|
271
|
+
* floating in the middle of a small screen is just a worse full page.
|
|
272
|
+
*
|
|
273
|
+
* WALLETS AND IFRAMES
|
|
274
|
+
*
|
|
275
|
+
* The authorize page asks for a wallet signature, and a browser extension is
|
|
276
|
+
* not obliged to inject itself into a cross-origin frame. When it has not,
|
|
277
|
+
* the framed page opens its own popup for the signing step — a top-level
|
|
278
|
+
* context where every wallet works. That handoff lives in the page rather
|
|
279
|
+
* than here, because only the page knows whether a provider answered. This
|
|
280
|
+
* side must simply not prevent it: the iframe carries no `sandbox`, because
|
|
281
|
+
* sandboxing without `allow-popups` would close the one exit the page has.
|
|
282
|
+
*/
|
|
283
|
+
const MODAL_STYLE_ID = "sweep-modal-styles";
|
|
284
|
+
/**
|
|
285
|
+
* Breakpoint below which the panel becomes a sheet.
|
|
286
|
+
*
|
|
287
|
+
* TIED TO THE PANEL WIDTH, not chosen separately. `.sweep-modal` keeps 24px of
|
|
288
|
+
* padding on each side, so a PANEL_MAX_WIDTH panel needs PANEL_MAX_WIDTH + 48
|
|
289
|
+
* of viewport before it starts being squeezed. Set the breakpoint any lower and
|
|
290
|
+
* there is a band of widths where the panel is neither its full size nor a
|
|
291
|
+
* sheet — it is a narrowed panel, which is the one shape nobody designed.
|
|
292
|
+
*
|
|
293
|
+
* That band existed: 520 + 48 = 568 against a 560 breakpoint left 561-568px
|
|
294
|
+
* squeezed. Small enough to never be noticed, and it would have grown to 88px
|
|
295
|
+
* wide the moment the panel did.
|
|
296
|
+
*/
|
|
297
|
+
const PANEL_MAX_WIDTH = 600;
|
|
298
|
+
const SHEET_MAX_WIDTH = PANEL_MAX_WIDTH + 48;
|
|
299
|
+
// The panel pops in like a dialog: it grows from 92% with a slight spring past
|
|
300
|
+
// full size while the backdrop dims and softens. On a phone it is a sheet that
|
|
301
|
+
// slides up from the bottom instead (see the media query).
|
|
302
|
+
const MODAL_CSS = `.sweep-modal{position:fixed;inset:0;z-index:2147483646;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(35,35,59,.55);-webkit-backdrop-filter:blur(3px);backdrop-filter:blur(3px);opacity:0;transition:opacity .2s ease}
|
|
303
|
+
.sweep-modal[data-open=true]{opacity:1}
|
|
304
|
+
.sweep-modal__panel{position:relative;width:100%;max-width:${PANEL_MAX_WIDTH}px;height:min(680px,calc(100vh - 48px));background:#fff;border-radius:14px;overflow:hidden;box-shadow:0 0 0 1px rgba(35,35,80,.08),0 30px 60px -30px rgba(35,35,80,.45);opacity:0;transform:translateY(16px) scale(.92);transition:transform .34s cubic-bezier(.2,1.25,.4,1),opacity .2s ease}
|
|
305
|
+
.sweep-modal[data-open=true] .sweep-modal__panel{opacity:1;transform:none}
|
|
306
|
+
.sweep-modal__frame{display:block;width:100%;height:100%;border:0}
|
|
307
|
+
.sweep-modal__close{position:absolute;top:10px;right:10px;width:30px;height:30px;display:flex;align-items:center;justify-content:center;border:0;border-radius:8px;background:rgba(244,244,251,.9);color:#5D5E73;cursor:pointer;font:inherit;font-size:17px;line-height:1}
|
|
308
|
+
.sweep-modal__close:hover{background:#ECECF3;color:#23233B}
|
|
309
|
+
.sweep-modal__close:focus-visible{outline:2px solid #4447D6;outline-offset:2px}
|
|
310
|
+
@media (max-width:${SHEET_MAX_WIDTH}px){.sweep-modal{padding:0}.sweep-modal__panel{max-width:none;height:100%;border-radius:0;opacity:1;transform:translateY(100%);transition:transform .32s cubic-bezier(.2,.8,.2,1)}.sweep-modal__close{top:8px;right:8px}}
|
|
311
|
+
@media (prefers-reduced-motion:reduce){.sweep-modal,.sweep-modal__panel{transition:none}.sweep-modal__panel{transform:none}}`;
|
|
312
|
+
/**
|
|
313
|
+
* Open the authorize page over the current one.
|
|
314
|
+
*
|
|
315
|
+
* Returns immediately — the callbacks carry the outcome.
|
|
316
|
+
*/
|
|
317
|
+
function openAuthorize(url, opts = {}) {
|
|
318
|
+
if (opts.injectStyles !== false)
|
|
319
|
+
injectModalStyles();
|
|
320
|
+
let frameOrigin;
|
|
321
|
+
try {
|
|
322
|
+
frameOrigin = new URL(url, window.location.href).origin;
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
throw new Error(`Sweep.openAuthorize: ${JSON.stringify(url)} is not a URL.`);
|
|
326
|
+
}
|
|
327
|
+
const root = document.createElement("div");
|
|
328
|
+
root.className = "sweep-modal";
|
|
329
|
+
root.setAttribute("role", "dialog");
|
|
330
|
+
root.setAttribute("aria-modal", "true");
|
|
331
|
+
root.setAttribute("aria-label", "Authorize payments");
|
|
332
|
+
const panel = document.createElement("div");
|
|
333
|
+
panel.className = "sweep-modal__panel";
|
|
334
|
+
const frame = document.createElement("iframe");
|
|
335
|
+
frame.className = "sweep-modal__frame";
|
|
336
|
+
// Deliberately no `sandbox`: see the note above — the page's popup fallback
|
|
337
|
+
// is the thing that makes this safe to attempt at all.
|
|
338
|
+
frame.setAttribute("allow", "clipboard-write; publickey-credentials-get *");
|
|
339
|
+
frame.setAttribute("title", "Authorize payments");
|
|
340
|
+
// `embed=1` is what tells the page to render as a panel and to speak this
|
|
341
|
+
// protocol. Without it the page behaves exactly as it always has.
|
|
342
|
+
frame.src = url + (url.indexOf("?") === -1 ? "?" : "&") + "embed=1";
|
|
343
|
+
const close = document.createElement("button");
|
|
344
|
+
close.type = "button";
|
|
345
|
+
close.className = "sweep-modal__close";
|
|
346
|
+
close.setAttribute("aria-label", "Close");
|
|
347
|
+
close.textContent = "\u00d7";
|
|
348
|
+
panel.appendChild(frame);
|
|
349
|
+
panel.appendChild(close);
|
|
350
|
+
root.appendChild(panel);
|
|
351
|
+
// What the page was scrolled to, and what had focus, so dismissing puts the
|
|
352
|
+
// merchant's page back exactly as the payer left it. This is the entire
|
|
353
|
+
// reason for not navigating away.
|
|
354
|
+
const prevOverflow = document.body.style.overflow;
|
|
355
|
+
const prevFocus = document.activeElement;
|
|
356
|
+
document.body.style.overflow = "hidden";
|
|
357
|
+
document.body.appendChild(root);
|
|
358
|
+
// Next frame, so the opening transition has somewhere to travel from.
|
|
359
|
+
requestAnimationFrame(() => root.setAttribute("data-open", "true"));
|
|
360
|
+
let done = false;
|
|
361
|
+
function teardown() {
|
|
362
|
+
if (done)
|
|
363
|
+
return;
|
|
364
|
+
done = true;
|
|
365
|
+
window.removeEventListener("message", onMessage);
|
|
366
|
+
document.removeEventListener("keydown", onKey);
|
|
367
|
+
document.body.style.overflow = prevOverflow;
|
|
368
|
+
root.remove();
|
|
369
|
+
if (prevFocus && typeof prevFocus.focus === "function")
|
|
370
|
+
prevFocus.focus();
|
|
371
|
+
}
|
|
372
|
+
function dismiss() {
|
|
373
|
+
const wasDone = done;
|
|
374
|
+
teardown();
|
|
375
|
+
if (!wasDone && opts.onCancelled)
|
|
376
|
+
opts.onCancelled();
|
|
377
|
+
}
|
|
378
|
+
function onKey(e) {
|
|
379
|
+
if (e.key === "Escape")
|
|
380
|
+
dismiss();
|
|
381
|
+
}
|
|
382
|
+
function onMessage(e) {
|
|
383
|
+
// Only this frame's origin may drive this modal. Without the check any
|
|
384
|
+
// page with a handle on this window could fake an authorization, and the
|
|
385
|
+
// merchant's `onAuthorized` is where a subscription gets switched on.
|
|
386
|
+
if (e.origin !== frameOrigin)
|
|
387
|
+
return;
|
|
388
|
+
if (e.source !== frame.contentWindow)
|
|
389
|
+
return;
|
|
390
|
+
const msg = e.data;
|
|
391
|
+
if (!msg || msg.source !== "sweep")
|
|
392
|
+
return;
|
|
393
|
+
if (msg.type === "ready") {
|
|
394
|
+
// Reply to the exact origin that spoke, never "*": from here on the
|
|
395
|
+
// page knows who framed it and can address us specifically.
|
|
396
|
+
frame.contentWindow?.postMessage({ source: "sweep", type: "host" }, e.origin);
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
if (msg.type === "authorized") {
|
|
400
|
+
teardown();
|
|
401
|
+
if (opts.onAuthorized)
|
|
402
|
+
opts.onAuthorized(msg.mandate ?? {});
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
if (msg.type === "cancelled") {
|
|
406
|
+
dismiss();
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
window.addEventListener("message", onMessage);
|
|
410
|
+
document.addEventListener("keydown", onKey);
|
|
411
|
+
close.addEventListener("click", dismiss);
|
|
412
|
+
root.addEventListener("click", (e) => {
|
|
413
|
+
// Backdrop only — a click that started inside the panel must not close it.
|
|
414
|
+
if (e.target === root)
|
|
415
|
+
dismiss();
|
|
416
|
+
});
|
|
417
|
+
return { close: dismiss };
|
|
418
|
+
}
|
|
419
|
+
function injectModalStyles() {
|
|
420
|
+
if (typeof document === "undefined")
|
|
421
|
+
return;
|
|
422
|
+
if (document.getElementById(MODAL_STYLE_ID))
|
|
423
|
+
return;
|
|
424
|
+
const tag = document.createElement("style");
|
|
425
|
+
tag.id = MODAL_STYLE_ID;
|
|
426
|
+
tag.textContent = MODAL_CSS;
|
|
427
|
+
document.head.appendChild(tag);
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* A button that opens the authorize modal.
|
|
431
|
+
*
|
|
432
|
+
* Same contract as `mount()` — your endpoint returns a URL — except the payer
|
|
433
|
+
* stays on your page. `mode: "redirect"` makes it behave exactly like
|
|
434
|
+
* `mount()`, which is the escape hatch if a modal turns out to be wrong for
|
|
435
|
+
* your flow.
|
|
436
|
+
*/
|
|
437
|
+
function authorize(target, opts) {
|
|
438
|
+
if (!opts || !opts.session) {
|
|
439
|
+
throw new Error("Sweep.authorize: `session` is required — your endpoint that returns a URL.");
|
|
440
|
+
}
|
|
441
|
+
return mount(target, {
|
|
442
|
+
...opts,
|
|
443
|
+
label: opts.label ?? "Authorize payments",
|
|
444
|
+
onRedirect: (url) => {
|
|
445
|
+
// Read per click, not captured at mount: a caller that flips `mode`
|
|
446
|
+
// between renders — a settings toggle, a test harness — gets the mode
|
|
447
|
+
// it is showing rather than the one it started with.
|
|
448
|
+
const mode = opts.mode ?? "modal";
|
|
449
|
+
if (mode === "redirect")
|
|
450
|
+
return; // let mount() navigate
|
|
451
|
+
if (mode === "popup") {
|
|
452
|
+
// Opened inside the click, so it is a user gesture and survives
|
|
453
|
+
// popup blockers.
|
|
454
|
+
window.open(url, "sweep_authorize", "width=520,height=720");
|
|
455
|
+
return false;
|
|
456
|
+
}
|
|
457
|
+
openAuthorize(url, opts);
|
|
458
|
+
return false; // we handled it
|
|
459
|
+
},
|
|
460
|
+
});
|
|
461
|
+
}
|
|
257
462
|
/** The global the CDN build defines. Same functions, one name to reach them. */
|
|
258
|
-
const Sweep = { mount, BUTTON_CSS };
|
|
463
|
+
const Sweep = { mount, authorize, openAuthorize, BUTTON_CSS, MODAL_CSS };
|
|
259
464
|
|
|
260
465
|
global.Sweep = Sweep;
|
|
261
466
|
})(typeof globalThis !== "undefined" ? globalThis : window);
|
package/package.json
CHANGED