@sweepconsole/js 0.1.0 → 0.2.0

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 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.0/dist/sweep.js"></script>
8
+ <script src="https://cdn.jsdelivr.net/npm/@sweepconsole/js@0.2.0/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.0/dist/button.css">
117
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@sweepconsole/js@0.2.0/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:520px;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:560px){.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,197 @@ 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
+ /** Breakpoint below which the panel becomes a sheet. Matches the design. */
282
+ const SHEET_MAX_WIDTH = 560;
283
+ // The panel pops in like a dialog: it grows from 92% with a slight spring past
284
+ // full size while the backdrop dims and softens. On a phone it is a sheet that
285
+ // slides up from the bottom instead (see the media query).
286
+ 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}
287
+ .sweep-modal[data-open=true]{opacity:1}
288
+ .sweep-modal__panel{position:relative;width:100%;max-width:520px;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}
289
+ .sweep-modal[data-open=true] .sweep-modal__panel{opacity:1;transform:none}
290
+ .sweep-modal__frame{display:block;width:100%;height:100%;border:0}
291
+ .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}
292
+ .sweep-modal__close:hover{background:#ECECF3;color:#23233B}
293
+ .sweep-modal__close:focus-visible{outline:2px solid #4447D6;outline-offset:2px}
294
+ @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}}
295
+ @media (prefers-reduced-motion:reduce){.sweep-modal,.sweep-modal__panel{transition:none}.sweep-modal__panel{transform:none}}`;
296
+ /**
297
+ * Open the authorize page over the current one.
298
+ *
299
+ * Returns immediately — the callbacks carry the outcome.
300
+ */
301
+ export function openAuthorize(url, opts = {}) {
302
+ if (opts.injectStyles !== false)
303
+ injectModalStyles();
304
+ let frameOrigin;
305
+ try {
306
+ frameOrigin = new URL(url, window.location.href).origin;
307
+ }
308
+ catch {
309
+ throw new Error(`Sweep.openAuthorize: ${JSON.stringify(url)} is not a URL.`);
310
+ }
311
+ const root = document.createElement("div");
312
+ root.className = "sweep-modal";
313
+ root.setAttribute("role", "dialog");
314
+ root.setAttribute("aria-modal", "true");
315
+ root.setAttribute("aria-label", "Authorize payments");
316
+ const panel = document.createElement("div");
317
+ panel.className = "sweep-modal__panel";
318
+ const frame = document.createElement("iframe");
319
+ frame.className = "sweep-modal__frame";
320
+ // Deliberately no `sandbox`: see the note above — the page's popup fallback
321
+ // is the thing that makes this safe to attempt at all.
322
+ frame.setAttribute("allow", "clipboard-write; publickey-credentials-get *");
323
+ frame.setAttribute("title", "Authorize payments");
324
+ // `embed=1` is what tells the page to render as a panel and to speak this
325
+ // protocol. Without it the page behaves exactly as it always has.
326
+ frame.src = url + (url.indexOf("?") === -1 ? "?" : "&") + "embed=1";
327
+ const close = document.createElement("button");
328
+ close.type = "button";
329
+ close.className = "sweep-modal__close";
330
+ close.setAttribute("aria-label", "Close");
331
+ close.textContent = "\u00d7";
332
+ panel.appendChild(frame);
333
+ panel.appendChild(close);
334
+ root.appendChild(panel);
335
+ // What the page was scrolled to, and what had focus, so dismissing puts the
336
+ // merchant's page back exactly as the payer left it. This is the entire
337
+ // reason for not navigating away.
338
+ const prevOverflow = document.body.style.overflow;
339
+ const prevFocus = document.activeElement;
340
+ document.body.style.overflow = "hidden";
341
+ document.body.appendChild(root);
342
+ // Next frame, so the opening transition has somewhere to travel from.
343
+ requestAnimationFrame(() => root.setAttribute("data-open", "true"));
344
+ let done = false;
345
+ function teardown() {
346
+ if (done)
347
+ return;
348
+ done = true;
349
+ window.removeEventListener("message", onMessage);
350
+ document.removeEventListener("keydown", onKey);
351
+ document.body.style.overflow = prevOverflow;
352
+ root.remove();
353
+ if (prevFocus && typeof prevFocus.focus === "function")
354
+ prevFocus.focus();
355
+ }
356
+ function dismiss() {
357
+ const wasDone = done;
358
+ teardown();
359
+ if (!wasDone && opts.onCancelled)
360
+ opts.onCancelled();
361
+ }
362
+ function onKey(e) {
363
+ if (e.key === "Escape")
364
+ dismiss();
365
+ }
366
+ function onMessage(e) {
367
+ // Only this frame's origin may drive this modal. Without the check any
368
+ // page with a handle on this window could fake an authorization, and the
369
+ // merchant's `onAuthorized` is where a subscription gets switched on.
370
+ if (e.origin !== frameOrigin)
371
+ return;
372
+ if (e.source !== frame.contentWindow)
373
+ return;
374
+ const msg = e.data;
375
+ if (!msg || msg.source !== "sweep")
376
+ return;
377
+ if (msg.type === "ready") {
378
+ // Reply to the exact origin that spoke, never "*": from here on the
379
+ // page knows who framed it and can address us specifically.
380
+ frame.contentWindow?.postMessage({ source: "sweep", type: "host" }, e.origin);
381
+ return;
382
+ }
383
+ if (msg.type === "authorized") {
384
+ teardown();
385
+ if (opts.onAuthorized)
386
+ opts.onAuthorized(msg.mandate ?? {});
387
+ return;
388
+ }
389
+ if (msg.type === "cancelled") {
390
+ dismiss();
391
+ }
392
+ }
393
+ window.addEventListener("message", onMessage);
394
+ document.addEventListener("keydown", onKey);
395
+ close.addEventListener("click", dismiss);
396
+ root.addEventListener("click", (e) => {
397
+ // Backdrop only — a click that started inside the panel must not close it.
398
+ if (e.target === root)
399
+ dismiss();
400
+ });
401
+ return { close: dismiss };
402
+ }
403
+ function injectModalStyles() {
404
+ if (typeof document === "undefined")
405
+ return;
406
+ if (document.getElementById(MODAL_STYLE_ID))
407
+ return;
408
+ const tag = document.createElement("style");
409
+ tag.id = MODAL_STYLE_ID;
410
+ tag.textContent = MODAL_CSS;
411
+ document.head.appendChild(tag);
412
+ }
413
+ /**
414
+ * A button that opens the authorize modal.
415
+ *
416
+ * Same contract as `mount()` — your endpoint returns a URL — except the payer
417
+ * stays on your page. `mode: "redirect"` makes it behave exactly like
418
+ * `mount()`, which is the escape hatch if a modal turns out to be wrong for
419
+ * your flow.
420
+ */
421
+ export function authorize(target, opts) {
422
+ if (!opts || !opts.session) {
423
+ throw new Error("Sweep.authorize: `session` is required — your endpoint that returns a URL.");
424
+ }
425
+ return mount(target, {
426
+ ...opts,
427
+ label: opts.label ?? "Authorize payments",
428
+ onRedirect: (url) => {
429
+ // Read per click, not captured at mount: a caller that flips `mode`
430
+ // between renders — a settings toggle, a test harness — gets the mode
431
+ // it is showing rather than the one it started with.
432
+ const mode = opts.mode ?? "modal";
433
+ if (mode === "redirect")
434
+ return; // let mount() navigate
435
+ if (mode === "popup") {
436
+ // Opened inside the click, so it is a user gesture and survives
437
+ // popup blockers.
438
+ window.open(url, "sweep_authorize", "width=520,height=720");
439
+ return false;
440
+ }
441
+ openAuthorize(url, opts);
442
+ return false; // we handled it
443
+ },
444
+ });
445
+ }
254
446
  /** The global the CDN build defines. Same functions, one name to reach them. */
255
- export const Sweep = { mount, BUTTON_CSS };
447
+ export const Sweep = { mount, authorize, openAuthorize, BUTTON_CSS, MODAL_CSS };
package/dist/sweep.js CHANGED
@@ -254,8 +254,200 @@
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
+ /** Breakpoint below which the panel becomes a sheet. Matches the design. */
285
+ const SHEET_MAX_WIDTH = 560;
286
+ // The panel pops in like a dialog: it grows from 92% with a slight spring past
287
+ // full size while the backdrop dims and softens. On a phone it is a sheet that
288
+ // slides up from the bottom instead (see the media query).
289
+ 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}
290
+ .sweep-modal[data-open=true]{opacity:1}
291
+ .sweep-modal__panel{position:relative;width:100%;max-width:520px;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}
292
+ .sweep-modal[data-open=true] .sweep-modal__panel{opacity:1;transform:none}
293
+ .sweep-modal__frame{display:block;width:100%;height:100%;border:0}
294
+ .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}
295
+ .sweep-modal__close:hover{background:#ECECF3;color:#23233B}
296
+ .sweep-modal__close:focus-visible{outline:2px solid #4447D6;outline-offset:2px}
297
+ @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}}
298
+ @media (prefers-reduced-motion:reduce){.sweep-modal,.sweep-modal__panel{transition:none}.sweep-modal__panel{transform:none}}`;
299
+ /**
300
+ * Open the authorize page over the current one.
301
+ *
302
+ * Returns immediately — the callbacks carry the outcome.
303
+ */
304
+ function openAuthorize(url, opts = {}) {
305
+ if (opts.injectStyles !== false)
306
+ injectModalStyles();
307
+ let frameOrigin;
308
+ try {
309
+ frameOrigin = new URL(url, window.location.href).origin;
310
+ }
311
+ catch {
312
+ throw new Error(`Sweep.openAuthorize: ${JSON.stringify(url)} is not a URL.`);
313
+ }
314
+ const root = document.createElement("div");
315
+ root.className = "sweep-modal";
316
+ root.setAttribute("role", "dialog");
317
+ root.setAttribute("aria-modal", "true");
318
+ root.setAttribute("aria-label", "Authorize payments");
319
+ const panel = document.createElement("div");
320
+ panel.className = "sweep-modal__panel";
321
+ const frame = document.createElement("iframe");
322
+ frame.className = "sweep-modal__frame";
323
+ // Deliberately no `sandbox`: see the note above — the page's popup fallback
324
+ // is the thing that makes this safe to attempt at all.
325
+ frame.setAttribute("allow", "clipboard-write; publickey-credentials-get *");
326
+ frame.setAttribute("title", "Authorize payments");
327
+ // `embed=1` is what tells the page to render as a panel and to speak this
328
+ // protocol. Without it the page behaves exactly as it always has.
329
+ frame.src = url + (url.indexOf("?") === -1 ? "?" : "&") + "embed=1";
330
+ const close = document.createElement("button");
331
+ close.type = "button";
332
+ close.className = "sweep-modal__close";
333
+ close.setAttribute("aria-label", "Close");
334
+ close.textContent = "\u00d7";
335
+ panel.appendChild(frame);
336
+ panel.appendChild(close);
337
+ root.appendChild(panel);
338
+ // What the page was scrolled to, and what had focus, so dismissing puts the
339
+ // merchant's page back exactly as the payer left it. This is the entire
340
+ // reason for not navigating away.
341
+ const prevOverflow = document.body.style.overflow;
342
+ const prevFocus = document.activeElement;
343
+ document.body.style.overflow = "hidden";
344
+ document.body.appendChild(root);
345
+ // Next frame, so the opening transition has somewhere to travel from.
346
+ requestAnimationFrame(() => root.setAttribute("data-open", "true"));
347
+ let done = false;
348
+ function teardown() {
349
+ if (done)
350
+ return;
351
+ done = true;
352
+ window.removeEventListener("message", onMessage);
353
+ document.removeEventListener("keydown", onKey);
354
+ document.body.style.overflow = prevOverflow;
355
+ root.remove();
356
+ if (prevFocus && typeof prevFocus.focus === "function")
357
+ prevFocus.focus();
358
+ }
359
+ function dismiss() {
360
+ const wasDone = done;
361
+ teardown();
362
+ if (!wasDone && opts.onCancelled)
363
+ opts.onCancelled();
364
+ }
365
+ function onKey(e) {
366
+ if (e.key === "Escape")
367
+ dismiss();
368
+ }
369
+ function onMessage(e) {
370
+ // Only this frame's origin may drive this modal. Without the check any
371
+ // page with a handle on this window could fake an authorization, and the
372
+ // merchant's `onAuthorized` is where a subscription gets switched on.
373
+ if (e.origin !== frameOrigin)
374
+ return;
375
+ if (e.source !== frame.contentWindow)
376
+ return;
377
+ const msg = e.data;
378
+ if (!msg || msg.source !== "sweep")
379
+ return;
380
+ if (msg.type === "ready") {
381
+ // Reply to the exact origin that spoke, never "*": from here on the
382
+ // page knows who framed it and can address us specifically.
383
+ frame.contentWindow?.postMessage({ source: "sweep", type: "host" }, e.origin);
384
+ return;
385
+ }
386
+ if (msg.type === "authorized") {
387
+ teardown();
388
+ if (opts.onAuthorized)
389
+ opts.onAuthorized(msg.mandate ?? {});
390
+ return;
391
+ }
392
+ if (msg.type === "cancelled") {
393
+ dismiss();
394
+ }
395
+ }
396
+ window.addEventListener("message", onMessage);
397
+ document.addEventListener("keydown", onKey);
398
+ close.addEventListener("click", dismiss);
399
+ root.addEventListener("click", (e) => {
400
+ // Backdrop only — a click that started inside the panel must not close it.
401
+ if (e.target === root)
402
+ dismiss();
403
+ });
404
+ return { close: dismiss };
405
+ }
406
+ function injectModalStyles() {
407
+ if (typeof document === "undefined")
408
+ return;
409
+ if (document.getElementById(MODAL_STYLE_ID))
410
+ return;
411
+ const tag = document.createElement("style");
412
+ tag.id = MODAL_STYLE_ID;
413
+ tag.textContent = MODAL_CSS;
414
+ document.head.appendChild(tag);
415
+ }
416
+ /**
417
+ * A button that opens the authorize modal.
418
+ *
419
+ * Same contract as `mount()` — your endpoint returns a URL — except the payer
420
+ * stays on your page. `mode: "redirect"` makes it behave exactly like
421
+ * `mount()`, which is the escape hatch if a modal turns out to be wrong for
422
+ * your flow.
423
+ */
424
+ function authorize(target, opts) {
425
+ if (!opts || !opts.session) {
426
+ throw new Error("Sweep.authorize: `session` is required — your endpoint that returns a URL.");
427
+ }
428
+ return mount(target, {
429
+ ...opts,
430
+ label: opts.label ?? "Authorize payments",
431
+ onRedirect: (url) => {
432
+ // Read per click, not captured at mount: a caller that flips `mode`
433
+ // between renders — a settings toggle, a test harness — gets the mode
434
+ // it is showing rather than the one it started with.
435
+ const mode = opts.mode ?? "modal";
436
+ if (mode === "redirect")
437
+ return; // let mount() navigate
438
+ if (mode === "popup") {
439
+ // Opened inside the click, so it is a user gesture and survives
440
+ // popup blockers.
441
+ window.open(url, "sweep_authorize", "width=520,height=720");
442
+ return false;
443
+ }
444
+ openAuthorize(url, opts);
445
+ return false; // we handled it
446
+ },
447
+ });
448
+ }
257
449
  /** The global the CDN build defines. Same functions, one name to reach them. */
258
- const Sweep = { mount, BUTTON_CSS };
450
+ const Sweep = { mount, authorize, openAuthorize, BUTTON_CSS, MODAL_CSS };
259
451
 
260
452
  global.Sweep = Sweep;
261
453
  })(typeof globalThis !== "undefined" ? globalThis : window);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sweepconsole/js",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Pay-with-USDC button for the browser \u2014 asks your server for a checkout URL and sends the payer there",
5
5
  "license": "MIT",
6
6
  "type": "module",