@sweepconsole/js 0.0.0-stage → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sweep Console
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,197 @@
1
- # Temporary Holding Version
1
+ # @sweepconsole/js
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A **Subscribe with USDC** button for your checkout. It asks your server for a URL and sends the payer there.
4
+
5
+ ~3KB gzipped, no dependencies.
6
+
7
+ ```html
8
+ <script src="https://cdn.jsdelivr.net/npm/@sweepconsole/js@0.2.0/dist/sweep.js"></script>
9
+ <button id="pay-usdc">Subscribe with USDC</button>
10
+
11
+ <script>
12
+ Sweep.mount("#pay-usdc", { session: "/api/sweep/session" });
13
+ </script>
14
+ ```
15
+
16
+ ```js
17
+ // your server — the payer's id comes from the cookie, never from the page
18
+ import Sweep from "@sweepconsole/node";
19
+ const sweep = new Sweep(process.env.SWEEP_SECRET_KEY);
20
+
21
+ // A plan you made in the Sweep portal:
22
+ app.post("/api/sweep/session", requireLogin, async (req, res) => {
23
+ const session = await sweep.checkout.sessions.create({
24
+ plan: "plan_pro",
25
+ externalRef: req.user.id,
26
+ successUrl: "https://app.example.com/welcome",
27
+ cancelUrl: "https://app.example.com/pricing",
28
+ });
29
+ res.json({ url: session.url });
30
+ });
31
+
32
+ // Or, on the external rail, where you do the billing yourself:
33
+ // const mandate = await sweep.mandates.create({
34
+ // externalRef: req.user.id,
35
+ // maxAmount: usdc("35.00"), // the ceiling, with headroom — not the price
36
+ // interval: "monthly",
37
+ // chains: ["base", "arbitrum", "optimism"],
38
+ // expiresAt: addYears(new Date(), 1),
39
+ // });
40
+ // res.json({ url: mandate.authorizationUrl });
41
+ ```
42
+
43
+ That is the whole integration. The payer signs in their wallet on Sweep's hosted
44
+ page, and `mandate.authorized` arrives on your webhook endpoint.
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
+
105
+ ## Why it will not call Sweep for you
106
+
107
+ This package has no API key and cannot create a session. Everything a browser
108
+ sends is something the person at the browser can change, so an `externalRef`
109
+ that came from the page is a user id the customer chose — and on this rail a
110
+ mandate is *standing* authority, so a forged one misattributes a recurring
111
+ charge rather than one payment. Your server already knows who is logged in.
112
+
113
+ For a public link — a pricing page, an invoice, a QR code — there is no identity
114
+ to protect, and a plain anchor is enough:
115
+
116
+ ```html
117
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@sweepconsole/js@0.2.0/dist/button.css">
118
+ <a class="sweep-pay" href="https://pay.sweepconsole.com/pay/plink_a1b2c3?ref=user_42">
119
+ Subscribe with USDC
120
+ </a>
121
+ ```
122
+
123
+ ## Install
124
+
125
+ ```sh
126
+ npm i @sweepconsole/js
127
+ ```
128
+
129
+ ```js
130
+ import { mount } from "@sweepconsole/js";
131
+ import "@sweepconsole/js/button.css"; // or let mount() inject it
132
+
133
+ mount("#pay-usdc", { session: "/api/sweep/session" });
134
+ ```
135
+
136
+ ## Options
137
+
138
+ | Option | Default | |
139
+ | --- | --- | --- |
140
+ | `session` | *required* | Your endpoint, POSTed to, answering `{ url }`. A function returning the URL also works. |
141
+ | `label` | the element's own text, else `"Subscribe with USDC"` | |
142
+ | `busyLabel` | `"Starting…"` | |
143
+ | `variant` | `"solid"` | `"solid"`, `"outline"`, `"light"` |
144
+ | `size` | `"md"` | `"sm"` for a compact row |
145
+ | `block` | `false` | Full width, for a checkout column |
146
+ | `body` | — | Extra JSON for your endpoint |
147
+ | `headers` | — | Extra headers, e.g. a CSRF token |
148
+ | `credentials` | `"same-origin"` | So your session cookie is sent |
149
+ | `injectStyles` | `true` | Set `false` under a CSP that forbids inline styles and link `button.css` |
150
+ | `onError` | logs | The button returns to rest first, so the payer can retry. Tell them something. |
151
+ | `onRedirect` | — | Return `false` to handle the navigation yourself |
152
+
153
+ `mount()` returns `{ element, destroy() }`.
154
+
155
+ Your endpoint's reply is read liberally: `url`, `checkout_url`,
156
+ `authorization_url`, their camelCase forms, and any of them nested under `data`.
157
+ An endpoint that forwards Sweep's own response verbatim works unchanged.
158
+
159
+ ## Theming
160
+
161
+ Four custom properties, and `font: inherit` so it never arrives in a typeface
162
+ you did not choose.
163
+
164
+ ```css
165
+ .sweep-pay {
166
+ --sweep-radius: 10px;
167
+ --sweep-bg: #111827;
168
+ --sweep-fg: #fff;
169
+ --sweep-border: #111827;
170
+ }
171
+ ```
172
+
173
+ ## Say "Subscribe", not "Pay"
174
+
175
+ Authorizing on this rail signs a standing permission with a monthly ceiling, not
176
+ a single payment. A payer who read "Pay $29" and then meets a wallet asking for
177
+ recurring authority has been misled by the button, and that is the moment they
178
+ abandon the checkout. Use "Pay" only if you are charging once and never again.
179
+
180
+ ## What it deliberately does not do
181
+
182
+ - **No iframe.** The hosted page needs a wallet, and injected providers are
183
+ unreliable inside a cross-origin frame. The button navigates the top level.
184
+ - **No wallet detection.** It never probes for `window.ethereum` to decide
185
+ whether to render. A mobile wallet browser fails that test, and the hosted
186
+ page handles a missing wallet better than a hidden button does.
187
+ - **No fulfilment signal.** Returning to your site means the payer came back,
188
+ nothing more. Grant access on `mandate.authorized`.
189
+
190
+ ## Tests
191
+
192
+ ```sh
193
+ node test/button.test.mjs
194
+ ```
195
+
196
+ Runs the built `dist/sweep.js` against a small DOM stub — the redirect, the
197
+ error path, the in-flight click guard, and style injection. Build first.
@@ -0,0 +1,16 @@
1
+ /* @sweepconsole/js — the button, standalone. Generated from src/index.ts; edit it there. */
2
+ .sweep-pay{--_bg:var(--sweep-bg,#201e1d);--_fg:var(--sweep-fg,#fff);--_edge:var(--sweep-border,var(--_bg));display:inline-flex;align-items:center;justify-content:center;gap:.625rem;box-sizing:border-box;font:inherit;font-size:.9375rem;font-weight:700;letter-spacing:-.01em;line-height:1.2;text-decoration:none;white-space:nowrap;padding:.8125rem 1.25rem;border:2px solid var(--_edge);border-radius:var(--sweep-radius,0);background:var(--_bg);color:var(--_fg);cursor:pointer;position:relative;transition:transform .12s ease,box-shadow .12s ease,opacity .12s ease}
3
+ .sweep-pay:hover{transform:translateY(-2px);box-shadow:0 2px 0 var(--_edge)}
4
+ .sweep-pay:active{transform:translateY(0);box-shadow:none}
5
+ .sweep-pay:focus-visible{outline:2px solid var(--_bg);outline-offset:3px}
6
+ .sweep-pay[aria-busy=true],.sweep-pay[disabled]{opacity:.6;pointer-events:none}
7
+ .sweep-pay--outline{--_bg:transparent;--_fg:currentColor;--_edge:currentColor}
8
+ .sweep-pay--light{--_bg:#fff;--_fg:#201e1d;--_edge:#201e1d}
9
+ .sweep-pay--sm{font-size:.8125rem;padding:.5625rem .875rem;gap:.4375rem}
10
+ .sweep-pay--block{display:flex;width:100%}
11
+ .sweep-pay__mark,.sweep-pay__spin{flex:none}
12
+ .sweep-pay__spin{display:none}
13
+ .sweep-pay[aria-busy=true] .sweep-pay__mark{display:none}
14
+ .sweep-pay[aria-busy=true] .sweep-pay__spin{display:block;animation:sweep-pay-spin .7s linear infinite}
15
+ @keyframes sweep-pay-spin{to{transform:rotate(360deg)}}
16
+ @media (prefers-reduced-motion:reduce){.sweep-pay{transition:none}.sweep-pay:hover{transform:none}.sweep-pay[aria-busy=true] .sweep-pay__spin{animation-duration:2.4s}}
@@ -0,0 +1,149 @@
1
+ /**
2
+ * @sweepconsole/js — the browser half of the rail.
3
+ *
4
+ * One button. It asks YOUR server for a URL and sends the payer there.
5
+ *
6
+ * It deliberately cannot talk to Sweep. Everything a browser sends is
7
+ * something the person at the browser can change, so a payer id that came from
8
+ * the page is a payer id the customer picked — and on this rail a mandate is
9
+ * standing authority, so a forged one misattributes a recurring charge rather
10
+ * than a single payment. The server half (`@sweepconsole/node`) holds the
11
+ * secret key, reads the id from its own session, and hands back a URL. All this
12
+ * file does is fetch that URL and follow it.
13
+ *
14
+ * No dependencies, no build step for the consumer, and no module system
15
+ * required: it is published as ESM for npm and as a classic script that defines
16
+ * `window.Sweep` for a CDN tag.
17
+ *
18
+ * IMPORTANT, if you edit this file: the CDN build is produced by wrapping tsc's
19
+ * output in an IIFE, which works only while this stays ONE file with no
20
+ * `import` and no `export { … }` block. scripts/build-cdn.mjs asserts both and
21
+ * fails the build rather than shipping something broken.
22
+ */
23
+ /** How the button looks. `solid` is the default. */
24
+ export type SweepVariant = "solid" | "outline" | "light";
25
+ export interface MountOptions {
26
+ /**
27
+ * Your endpoint, which creates the session and returns a URL. Called with
28
+ * POST. Must answer `{ url }` — `checkout_url`, `authorization_url`, and any
29
+ * of those nested under `data`, are also accepted, so an endpoint that simply
30
+ * forwards Sweep's own response works unchanged.
31
+ *
32
+ * A function is also allowed, for a page that already has its own fetching
33
+ * conventions: return the URL (or a promise of it) and nothing is fetched
34
+ * here.
35
+ */
36
+ session: string | (() => string | Promise<string>);
37
+ /**
38
+ * The button's text. Defaults to the element's existing text, or
39
+ * "Subscribe with USDC".
40
+ *
41
+ * Prefer "Subscribe" to "Pay" unless you are charging once. Authorizing on
42
+ * this rail signs a standing permission with a ceiling, and a payer who read
43
+ * "Pay" meets a wallet asking for more than they agreed to.
44
+ */
45
+ label?: string;
46
+ /** Shown while your endpoint is working. Defaults to "Starting…". */
47
+ busyLabel?: string;
48
+ variant?: SweepVariant;
49
+ size?: "md" | "sm";
50
+ /** Fill the container's width, for a checkout column. */
51
+ block?: boolean;
52
+ /** Extra JSON to post to your endpoint. */
53
+ body?: Record<string, unknown>;
54
+ /** Extra headers, e.g. a CSRF token. */
55
+ headers?: Record<string, string>;
56
+ /** Defaults to "same-origin", so your session cookie is sent. */
57
+ credentials?: RequestCredentials;
58
+ /**
59
+ * Set false if your Content-Security-Policy forbids inline styles; link
60
+ * `button.css` from the package instead.
61
+ */
62
+ injectStyles?: boolean;
63
+ /**
64
+ * Called when the session could not be created. The button returns to its
65
+ * resting state first, so the payer can try again. Without one, the error is
66
+ * logged — tell the payer something instead.
67
+ */
68
+ onError?: (error: Error) => void;
69
+ /**
70
+ * Called with the URL just before leaving the page. Return `false` to take
71
+ * over the navigation yourself.
72
+ */
73
+ onRedirect?: (url: string) => void | boolean;
74
+ }
75
+ export interface SweepButton {
76
+ /** The button element, mounted or created. */
77
+ readonly element: HTMLElement;
78
+ /** Remove the handler and undo what mounting changed. */
79
+ destroy(): void;
80
+ }
81
+ /**
82
+ * The stylesheet, also published as `button.css` for anyone who would rather
83
+ * link it. Four custom properties are the whole theming surface, and
84
+ * `font: inherit` means the button never arrives in a typeface the merchant
85
+ * did not choose.
86
+ */
87
+ export declare const BUTTON_CSS = ".sweep-pay{--_bg:var(--sweep-bg,#201e1d);--_fg:var(--sweep-fg,#fff);--_edge:var(--sweep-border,var(--_bg));display:inline-flex;align-items:center;justify-content:center;gap:.625rem;box-sizing:border-box;font:inherit;font-size:.9375rem;font-weight:700;letter-spacing:-.01em;line-height:1.2;text-decoration:none;white-space:nowrap;padding:.8125rem 1.25rem;border:2px solid var(--_edge);border-radius:var(--sweep-radius,0);background:var(--_bg);color:var(--_fg);cursor:pointer;position:relative;transition:transform .12s ease,box-shadow .12s ease,opacity .12s ease}\n.sweep-pay:hover{transform:translateY(-2px);box-shadow:0 2px 0 var(--_edge)}\n.sweep-pay:active{transform:translateY(0);box-shadow:none}\n.sweep-pay:focus-visible{outline:2px solid var(--_bg);outline-offset:3px}\n.sweep-pay[aria-busy=true],.sweep-pay[disabled]{opacity:.6;pointer-events:none}\n.sweep-pay--outline{--_bg:transparent;--_fg:currentColor;--_edge:currentColor}\n.sweep-pay--light{--_bg:#fff;--_fg:#201e1d;--_edge:#201e1d}\n.sweep-pay--sm{font-size:.8125rem;padding:.5625rem .875rem;gap:.4375rem}\n.sweep-pay--block{display:flex;width:100%}\n.sweep-pay__mark,.sweep-pay__spin{flex:none}\n.sweep-pay__spin{display:none}\n.sweep-pay[aria-busy=true] .sweep-pay__mark{display:none}\n.sweep-pay[aria-busy=true] .sweep-pay__spin{display:block;animation:sweep-pay-spin .7s linear infinite}\n@keyframes sweep-pay-spin{to{transform:rotate(360deg)}}\n@media (prefers-reduced-motion:reduce){.sweep-pay{transition:none}.sweep-pay:hover{transform:none}.sweep-pay[aria-busy=true] .sweep-pay__spin{animation-duration:2.4s}}";
88
+ /**
89
+ * Turn an element into the button, or create one inside a container.
90
+ *
91
+ * A `<button>` or `<a>` is used as-is, so the merchant's own markup and any
92
+ * framework ref survive. Anything else gets a real `<button>` appended —
93
+ * never a styled div, which cannot be reached by keyboard.
94
+ */
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;
142
+ /** The global the CDN build defines. Same functions, one name to reach them. */
143
+ export declare const Sweep: {
144
+ mount: typeof mount;
145
+ authorize: typeof authorize;
146
+ openAuthorize: typeof openAuthorize;
147
+ BUTTON_CSS: string;
148
+ MODAL_CSS: string;
149
+ };
package/dist/index.js ADDED
@@ -0,0 +1,447 @@
1
+ /**
2
+ * @sweepconsole/js — the browser half of the rail.
3
+ *
4
+ * One button. It asks YOUR server for a URL and sends the payer there.
5
+ *
6
+ * It deliberately cannot talk to Sweep. Everything a browser sends is
7
+ * something the person at the browser can change, so a payer id that came from
8
+ * the page is a payer id the customer picked — and on this rail a mandate is
9
+ * standing authority, so a forged one misattributes a recurring charge rather
10
+ * than a single payment. The server half (`@sweepconsole/node`) holds the
11
+ * secret key, reads the id from its own session, and hands back a URL. All this
12
+ * file does is fetch that URL and follow it.
13
+ *
14
+ * No dependencies, no build step for the consumer, and no module system
15
+ * required: it is published as ESM for npm and as a classic script that defines
16
+ * `window.Sweep` for a CDN tag.
17
+ *
18
+ * IMPORTANT, if you edit this file: the CDN build is produced by wrapping tsc's
19
+ * output in an IIFE, which works only while this stays ONE file with no
20
+ * `import` and no `export { … }` block. scripts/build-cdn.mjs asserts both and
21
+ * fails the build rather than shipping something broken.
22
+ */
23
+ const STYLE_ID = "sweep-pay-styles";
24
+ /* The USDC mark and the busy spinner. Inlined because a button that waits on a
25
+ network request to look like itself is worse than one extra kilobyte. */
26
+ const COIN_SVG = '<svg class="sweep-pay__mark" width="18" height="18" viewBox="0 0 24 24" aria-hidden="true" focusable="false">' +
27
+ '<circle cx="12" cy="12" r="11" fill="#2775ca"/>' +
28
+ '<path d="M12 5.3v1.4c2 .2 3.3 1.3 3.5 2.9h-1.8c-.2-.8-.8-1.3-1.7-1.5v3.1c2.2.5 3.7 1.1 3.7 3 0 1.8-1.4 3-3.7 3.2v1.3h-1.3v-1.3c-2.2-.2-3.6-1.4-3.8-3.2h1.8c.2.9.9 1.5 2 1.7v-3.3C8.6 12.1 7.2 11.4 7.2 9.6c0-1.7 1.4-2.8 3.5-3V5.3H12zm-1.3 2.7c-1 .2-1.6.7-1.6 1.5 0 .8.5 1.2 1.6 1.5V8zm1.3 5.1v3.1c1.1-.2 1.7-.7 1.7-1.6 0-.8-.6-1.2-1.7-1.5z" fill="#fff"/>' +
29
+ "</svg>";
30
+ const SPINNER_SVG = '<svg class="sweep-pay__spin" width="18" height="18" viewBox="0 0 24 24" aria-hidden="true" focusable="false">' +
31
+ '<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-opacity=".3" stroke-width="2.5"/>' +
32
+ '<path d="M12 3a9 9 0 0 1 9 9" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/>' +
33
+ "</svg>";
34
+ /**
35
+ * The stylesheet, also published as `button.css` for anyone who would rather
36
+ * link it. Four custom properties are the whole theming surface, and
37
+ * `font: inherit` means the button never arrives in a typeface the merchant
38
+ * did not choose.
39
+ */
40
+ export const BUTTON_CSS = `.sweep-pay{--_bg:var(--sweep-bg,#201e1d);--_fg:var(--sweep-fg,#fff);--_edge:var(--sweep-border,var(--_bg));display:inline-flex;align-items:center;justify-content:center;gap:.625rem;box-sizing:border-box;font:inherit;font-size:.9375rem;font-weight:700;letter-spacing:-.01em;line-height:1.2;text-decoration:none;white-space:nowrap;padding:.8125rem 1.25rem;border:2px solid var(--_edge);border-radius:var(--sweep-radius,0);background:var(--_bg);color:var(--_fg);cursor:pointer;position:relative;transition:transform .12s ease,box-shadow .12s ease,opacity .12s ease}
41
+ .sweep-pay:hover{transform:translateY(-2px);box-shadow:0 2px 0 var(--_edge)}
42
+ .sweep-pay:active{transform:translateY(0);box-shadow:none}
43
+ .sweep-pay:focus-visible{outline:2px solid var(--_bg);outline-offset:3px}
44
+ .sweep-pay[aria-busy=true],.sweep-pay[disabled]{opacity:.6;pointer-events:none}
45
+ .sweep-pay--outline{--_bg:transparent;--_fg:currentColor;--_edge:currentColor}
46
+ .sweep-pay--light{--_bg:#fff;--_fg:#201e1d;--_edge:#201e1d}
47
+ .sweep-pay--sm{font-size:.8125rem;padding:.5625rem .875rem;gap:.4375rem}
48
+ .sweep-pay--block{display:flex;width:100%}
49
+ .sweep-pay__mark,.sweep-pay__spin{flex:none}
50
+ .sweep-pay__spin{display:none}
51
+ .sweep-pay[aria-busy=true] .sweep-pay__mark{display:none}
52
+ .sweep-pay[aria-busy=true] .sweep-pay__spin{display:block;animation:sweep-pay-spin .7s linear infinite}
53
+ @keyframes sweep-pay-spin{to{transform:rotate(360deg)}}
54
+ @media (prefers-reduced-motion:reduce){.sweep-pay{transition:none}.sweep-pay:hover{transform:none}.sweep-pay[aria-busy=true] .sweep-pay__spin{animation-duration:2.4s}}`;
55
+ function injectStyles() {
56
+ if (typeof document === "undefined")
57
+ return;
58
+ if (document.getElementById(STYLE_ID))
59
+ return;
60
+ const style = document.createElement("style");
61
+ style.id = STYLE_ID;
62
+ style.textContent = BUTTON_CSS;
63
+ document.head.appendChild(style);
64
+ }
65
+ function resolveTarget(target) {
66
+ const el = typeof target === "string" ? document.querySelector(target) : target;
67
+ if (!el) {
68
+ // Loudly, because the usual cause is a script running before the element
69
+ // exists, and a button that silently never appears is hours of confusion.
70
+ throw new Error(`Sweep.mount: nothing matched ${JSON.stringify(target)}. ` +
71
+ `Run it after the element exists, or pass the element itself.`);
72
+ }
73
+ return el;
74
+ }
75
+ /** Liberal on the way in: anything that looks like the URL counts. */
76
+ function urlFrom(payload) {
77
+ if (typeof payload === "string")
78
+ return payload || null;
79
+ if (!payload || typeof payload !== "object")
80
+ return null;
81
+ const o = payload;
82
+ for (const key of ["url", "checkout_url", "authorization_url", "checkoutUrl", "authorizationUrl"]) {
83
+ const v = o[key];
84
+ if (typeof v === "string" && v)
85
+ return v;
86
+ }
87
+ // An endpoint that forwards Sweep's reply verbatim nests it under `data`.
88
+ if (o["data"] && typeof o["data"] === "object")
89
+ return urlFrom(o["data"]);
90
+ return null;
91
+ }
92
+ async function messageFrom(res) {
93
+ try {
94
+ const body = (await res.json());
95
+ const err = body["error"];
96
+ if (err && typeof err === "object") {
97
+ const m = err["message"];
98
+ if (typeof m === "string" && m)
99
+ return m;
100
+ }
101
+ if (typeof body["message"] === "string" && body["message"])
102
+ return body["message"];
103
+ }
104
+ catch {
105
+ /* not JSON; fall through to the status */
106
+ }
107
+ return `We couldn't start your payment (HTTP ${res.status}).`;
108
+ }
109
+ /**
110
+ * Refuse to navigate anywhere that could execute script.
111
+ *
112
+ * The URL arrives from the merchant's own endpoint, so this is not a defence
113
+ * against Sweep — it is a defence against that endpoint being made to reflect
114
+ * something. `location.assign("javascript:…")` runs the script in the
115
+ * merchant's page, with their session and their DOM, which turns a reflected
116
+ * value into stored XSS on a checkout.
117
+ *
118
+ * A relative URL carries no scheme and inherits the page's, so it needs no
119
+ * check. An absolute one must be http(s); another host is allowed, since
120
+ * plenty of merchants host checkout on a different one.
121
+ */
122
+ function checkedUrl(url) {
123
+ const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(url);
124
+ if (!scheme)
125
+ return url;
126
+ const protocol = (scheme[1] ?? "").toLowerCase();
127
+ if (protocol !== "http" && protocol !== "https") {
128
+ throw new Error(`Sweep: refusing to navigate to a ${protocol}: URL. Your endpoint must return an http(s) ` +
129
+ `address or a path.`);
130
+ }
131
+ return url;
132
+ }
133
+ async function fetchUrl(opts) {
134
+ if (typeof opts.session === "function") {
135
+ const url = await opts.session();
136
+ if (!url)
137
+ throw new Error("Sweep.mount: the session function returned no URL.");
138
+ return checkedUrl(url);
139
+ }
140
+ const hasBody = !!opts.body;
141
+ const res = await fetch(opts.session, {
142
+ method: "POST",
143
+ credentials: opts.credentials ?? "same-origin",
144
+ headers: {
145
+ accept: "application/json",
146
+ ...(hasBody ? { "content-type": "application/json" } : {}),
147
+ ...opts.headers,
148
+ },
149
+ ...(hasBody ? { body: JSON.stringify(opts.body) } : {}),
150
+ });
151
+ if (!res.ok)
152
+ throw new Error(await messageFrom(res));
153
+ const url = urlFrom(await res.json().catch(() => null));
154
+ if (!url) {
155
+ throw new Error("Sweep.mount: your endpoint answered without a URL. Return { url } — " +
156
+ "for the external rail that is the mandate's authorizationUrl.");
157
+ }
158
+ return checkedUrl(url);
159
+ }
160
+ /**
161
+ * Turn an element into the button, or create one inside a container.
162
+ *
163
+ * A `<button>` or `<a>` is used as-is, so the merchant's own markup and any
164
+ * framework ref survive. Anything else gets a real `<button>` appended —
165
+ * never a styled div, which cannot be reached by keyboard.
166
+ */
167
+ export function mount(target, opts) {
168
+ if (!opts || !opts.session) {
169
+ throw new Error("Sweep.mount: `session` is required — your endpoint that returns a URL.");
170
+ }
171
+ if (opts.injectStyles !== false)
172
+ injectStyles();
173
+ const found = resolveTarget(target);
174
+ const tag = found.tagName.toLowerCase();
175
+ const reuse = tag === "button" || tag === "a";
176
+ const el = reuse ? found : document.createElement("button");
177
+ if (!reuse)
178
+ found.appendChild(el);
179
+ if (el.tagName.toLowerCase() === "button" && !el.getAttribute("type")) {
180
+ // Without this it submits any form it happens to sit inside.
181
+ el.setAttribute("type", "button");
182
+ }
183
+ const restore = {
184
+ className: el.className,
185
+ html: el.innerHTML,
186
+ };
187
+ // The merchant's own text wins over our default, and an explicit `label`
188
+ // wins over both. An element with only whitespace in it counts as empty.
189
+ const existing = reuse ? el.textContent?.trim() : "";
190
+ const label = opts.label || existing || "Subscribe with USDC";
191
+ const busyLabel = opts.busyLabel ?? "Starting…";
192
+ el.classList.add("sweep-pay");
193
+ if (opts.variant === "outline")
194
+ el.classList.add("sweep-pay--outline");
195
+ if (opts.variant === "light")
196
+ el.classList.add("sweep-pay--light");
197
+ if (opts.size === "sm")
198
+ el.classList.add("sweep-pay--sm");
199
+ if (opts.block)
200
+ el.classList.add("sweep-pay--block");
201
+ // Our own constant markup, then the label by textContent — a merchant's
202
+ // string never reaches innerHTML.
203
+ el.innerHTML = COIN_SVG + SPINNER_SVG + '<span class="sweep-pay__label"></span>';
204
+ const labelEl = el.querySelector(".sweep-pay__label");
205
+ labelEl.textContent = label;
206
+ let busy = false;
207
+ const onClick = (event) => {
208
+ event.preventDefault();
209
+ // Covers the second click, and Enter on a button CSS has only made
210
+ // pointer-inert.
211
+ if (busy)
212
+ return;
213
+ busy = true;
214
+ el.setAttribute("aria-busy", "true");
215
+ labelEl.textContent = busyLabel;
216
+ void fetchUrl(opts)
217
+ .then((url) => {
218
+ if (opts.onRedirect && opts.onRedirect(url) === false) {
219
+ busy = false;
220
+ el.removeAttribute("aria-busy");
221
+ labelEl.textContent = label;
222
+ return;
223
+ }
224
+ // assign, not replace: the payer can come back with the back button.
225
+ window.location.assign(url);
226
+ })
227
+ .catch((e) => {
228
+ busy = false;
229
+ el.removeAttribute("aria-busy");
230
+ labelEl.textContent = label;
231
+ const error = e instanceof Error ? e : new Error(String(e));
232
+ if (opts.onError)
233
+ opts.onError(error);
234
+ else
235
+ console.error("[sweep] could not start the payment:", error);
236
+ });
237
+ };
238
+ el.addEventListener("click", onClick);
239
+ return {
240
+ element: el,
241
+ destroy() {
242
+ el.removeEventListener("click", onClick);
243
+ el.removeAttribute("aria-busy");
244
+ if (reuse) {
245
+ el.className = restore.className;
246
+ el.innerHTML = restore.html;
247
+ }
248
+ else {
249
+ el.remove();
250
+ }
251
+ },
252
+ };
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
+ }
446
+ /** The global the CDN build defines. Same functions, one name to reach them. */
447
+ export const Sweep = { mount, authorize, openAuthorize, BUTTON_CSS, MODAL_CSS };
package/dist/sweep.js ADDED
@@ -0,0 +1,453 @@
1
+ /* @sweepconsole/js — https://sweepconsole.com. MIT. */
2
+ (function (global) {
3
+ "use strict";
4
+ /**
5
+ * @sweepconsole/js — the browser half of the rail.
6
+ *
7
+ * One button. It asks YOUR server for a URL and sends the payer there.
8
+ *
9
+ * It deliberately cannot talk to Sweep. Everything a browser sends is
10
+ * something the person at the browser can change, so a payer id that came from
11
+ * the page is a payer id the customer picked — and on this rail a mandate is
12
+ * standing authority, so a forged one misattributes a recurring charge rather
13
+ * than a single payment. The server half (`@sweepconsole/node`) holds the
14
+ * secret key, reads the id from its own session, and hands back a URL. All this
15
+ * file does is fetch that URL and follow it.
16
+ *
17
+ * No dependencies, no build step for the consumer, and no module system
18
+ * required: it is published as ESM for npm and as a classic script that defines
19
+ * `window.Sweep` for a CDN tag.
20
+ *
21
+ * IMPORTANT, if you edit this file: the CDN build is produced by wrapping tsc's
22
+ * output in an IIFE, which works only while this stays ONE file with no
23
+ * `import` and no `export { … }` block. scripts/build-cdn.mjs asserts both and
24
+ * fails the build rather than shipping something broken.
25
+ */
26
+ const STYLE_ID = "sweep-pay-styles";
27
+ /* The USDC mark and the busy spinner. Inlined because a button that waits on a
28
+ network request to look like itself is worse than one extra kilobyte. */
29
+ const COIN_SVG = '<svg class="sweep-pay__mark" width="18" height="18" viewBox="0 0 24 24" aria-hidden="true" focusable="false">' +
30
+ '<circle cx="12" cy="12" r="11" fill="#2775ca"/>' +
31
+ '<path d="M12 5.3v1.4c2 .2 3.3 1.3 3.5 2.9h-1.8c-.2-.8-.8-1.3-1.7-1.5v3.1c2.2.5 3.7 1.1 3.7 3 0 1.8-1.4 3-3.7 3.2v1.3h-1.3v-1.3c-2.2-.2-3.6-1.4-3.8-3.2h1.8c.2.9.9 1.5 2 1.7v-3.3C8.6 12.1 7.2 11.4 7.2 9.6c0-1.7 1.4-2.8 3.5-3V5.3H12zm-1.3 2.7c-1 .2-1.6.7-1.6 1.5 0 .8.5 1.2 1.6 1.5V8zm1.3 5.1v3.1c1.1-.2 1.7-.7 1.7-1.6 0-.8-.6-1.2-1.7-1.5z" fill="#fff"/>' +
32
+ "</svg>";
33
+ const SPINNER_SVG = '<svg class="sweep-pay__spin" width="18" height="18" viewBox="0 0 24 24" aria-hidden="true" focusable="false">' +
34
+ '<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-opacity=".3" stroke-width="2.5"/>' +
35
+ '<path d="M12 3a9 9 0 0 1 9 9" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/>' +
36
+ "</svg>";
37
+ /**
38
+ * The stylesheet, also published as `button.css` for anyone who would rather
39
+ * link it. Four custom properties are the whole theming surface, and
40
+ * `font: inherit` means the button never arrives in a typeface the merchant
41
+ * did not choose.
42
+ */
43
+ const BUTTON_CSS = `.sweep-pay{--_bg:var(--sweep-bg,#201e1d);--_fg:var(--sweep-fg,#fff);--_edge:var(--sweep-border,var(--_bg));display:inline-flex;align-items:center;justify-content:center;gap:.625rem;box-sizing:border-box;font:inherit;font-size:.9375rem;font-weight:700;letter-spacing:-.01em;line-height:1.2;text-decoration:none;white-space:nowrap;padding:.8125rem 1.25rem;border:2px solid var(--_edge);border-radius:var(--sweep-radius,0);background:var(--_bg);color:var(--_fg);cursor:pointer;position:relative;transition:transform .12s ease,box-shadow .12s ease,opacity .12s ease}
44
+ .sweep-pay:hover{transform:translateY(-2px);box-shadow:0 2px 0 var(--_edge)}
45
+ .sweep-pay:active{transform:translateY(0);box-shadow:none}
46
+ .sweep-pay:focus-visible{outline:2px solid var(--_bg);outline-offset:3px}
47
+ .sweep-pay[aria-busy=true],.sweep-pay[disabled]{opacity:.6;pointer-events:none}
48
+ .sweep-pay--outline{--_bg:transparent;--_fg:currentColor;--_edge:currentColor}
49
+ .sweep-pay--light{--_bg:#fff;--_fg:#201e1d;--_edge:#201e1d}
50
+ .sweep-pay--sm{font-size:.8125rem;padding:.5625rem .875rem;gap:.4375rem}
51
+ .sweep-pay--block{display:flex;width:100%}
52
+ .sweep-pay__mark,.sweep-pay__spin{flex:none}
53
+ .sweep-pay__spin{display:none}
54
+ .sweep-pay[aria-busy=true] .sweep-pay__mark{display:none}
55
+ .sweep-pay[aria-busy=true] .sweep-pay__spin{display:block;animation:sweep-pay-spin .7s linear infinite}
56
+ @keyframes sweep-pay-spin{to{transform:rotate(360deg)}}
57
+ @media (prefers-reduced-motion:reduce){.sweep-pay{transition:none}.sweep-pay:hover{transform:none}.sweep-pay[aria-busy=true] .sweep-pay__spin{animation-duration:2.4s}}`;
58
+ function injectStyles() {
59
+ if (typeof document === "undefined")
60
+ return;
61
+ if (document.getElementById(STYLE_ID))
62
+ return;
63
+ const style = document.createElement("style");
64
+ style.id = STYLE_ID;
65
+ style.textContent = BUTTON_CSS;
66
+ document.head.appendChild(style);
67
+ }
68
+ function resolveTarget(target) {
69
+ const el = typeof target === "string" ? document.querySelector(target) : target;
70
+ if (!el) {
71
+ // Loudly, because the usual cause is a script running before the element
72
+ // exists, and a button that silently never appears is hours of confusion.
73
+ throw new Error(`Sweep.mount: nothing matched ${JSON.stringify(target)}. ` +
74
+ `Run it after the element exists, or pass the element itself.`);
75
+ }
76
+ return el;
77
+ }
78
+ /** Liberal on the way in: anything that looks like the URL counts. */
79
+ function urlFrom(payload) {
80
+ if (typeof payload === "string")
81
+ return payload || null;
82
+ if (!payload || typeof payload !== "object")
83
+ return null;
84
+ const o = payload;
85
+ for (const key of ["url", "checkout_url", "authorization_url", "checkoutUrl", "authorizationUrl"]) {
86
+ const v = o[key];
87
+ if (typeof v === "string" && v)
88
+ return v;
89
+ }
90
+ // An endpoint that forwards Sweep's reply verbatim nests it under `data`.
91
+ if (o["data"] && typeof o["data"] === "object")
92
+ return urlFrom(o["data"]);
93
+ return null;
94
+ }
95
+ async function messageFrom(res) {
96
+ try {
97
+ const body = (await res.json());
98
+ const err = body["error"];
99
+ if (err && typeof err === "object") {
100
+ const m = err["message"];
101
+ if (typeof m === "string" && m)
102
+ return m;
103
+ }
104
+ if (typeof body["message"] === "string" && body["message"])
105
+ return body["message"];
106
+ }
107
+ catch {
108
+ /* not JSON; fall through to the status */
109
+ }
110
+ return `We couldn't start your payment (HTTP ${res.status}).`;
111
+ }
112
+ /**
113
+ * Refuse to navigate anywhere that could execute script.
114
+ *
115
+ * The URL arrives from the merchant's own endpoint, so this is not a defence
116
+ * against Sweep — it is a defence against that endpoint being made to reflect
117
+ * something. `location.assign("javascript:…")` runs the script in the
118
+ * merchant's page, with their session and their DOM, which turns a reflected
119
+ * value into stored XSS on a checkout.
120
+ *
121
+ * A relative URL carries no scheme and inherits the page's, so it needs no
122
+ * check. An absolute one must be http(s); another host is allowed, since
123
+ * plenty of merchants host checkout on a different one.
124
+ */
125
+ function checkedUrl(url) {
126
+ const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(url);
127
+ if (!scheme)
128
+ return url;
129
+ const protocol = (scheme[1] ?? "").toLowerCase();
130
+ if (protocol !== "http" && protocol !== "https") {
131
+ throw new Error(`Sweep: refusing to navigate to a ${protocol}: URL. Your endpoint must return an http(s) ` +
132
+ `address or a path.`);
133
+ }
134
+ return url;
135
+ }
136
+ async function fetchUrl(opts) {
137
+ if (typeof opts.session === "function") {
138
+ const url = await opts.session();
139
+ if (!url)
140
+ throw new Error("Sweep.mount: the session function returned no URL.");
141
+ return checkedUrl(url);
142
+ }
143
+ const hasBody = !!opts.body;
144
+ const res = await fetch(opts.session, {
145
+ method: "POST",
146
+ credentials: opts.credentials ?? "same-origin",
147
+ headers: {
148
+ accept: "application/json",
149
+ ...(hasBody ? { "content-type": "application/json" } : {}),
150
+ ...opts.headers,
151
+ },
152
+ ...(hasBody ? { body: JSON.stringify(opts.body) } : {}),
153
+ });
154
+ if (!res.ok)
155
+ throw new Error(await messageFrom(res));
156
+ const url = urlFrom(await res.json().catch(() => null));
157
+ if (!url) {
158
+ throw new Error("Sweep.mount: your endpoint answered without a URL. Return { url } — " +
159
+ "for the external rail that is the mandate's authorizationUrl.");
160
+ }
161
+ return checkedUrl(url);
162
+ }
163
+ /**
164
+ * Turn an element into the button, or create one inside a container.
165
+ *
166
+ * A `<button>` or `<a>` is used as-is, so the merchant's own markup and any
167
+ * framework ref survive. Anything else gets a real `<button>` appended —
168
+ * never a styled div, which cannot be reached by keyboard.
169
+ */
170
+ function mount(target, opts) {
171
+ if (!opts || !opts.session) {
172
+ throw new Error("Sweep.mount: `session` is required — your endpoint that returns a URL.");
173
+ }
174
+ if (opts.injectStyles !== false)
175
+ injectStyles();
176
+ const found = resolveTarget(target);
177
+ const tag = found.tagName.toLowerCase();
178
+ const reuse = tag === "button" || tag === "a";
179
+ const el = reuse ? found : document.createElement("button");
180
+ if (!reuse)
181
+ found.appendChild(el);
182
+ if (el.tagName.toLowerCase() === "button" && !el.getAttribute("type")) {
183
+ // Without this it submits any form it happens to sit inside.
184
+ el.setAttribute("type", "button");
185
+ }
186
+ const restore = {
187
+ className: el.className,
188
+ html: el.innerHTML,
189
+ };
190
+ // The merchant's own text wins over our default, and an explicit `label`
191
+ // wins over both. An element with only whitespace in it counts as empty.
192
+ const existing = reuse ? el.textContent?.trim() : "";
193
+ const label = opts.label || existing || "Subscribe with USDC";
194
+ const busyLabel = opts.busyLabel ?? "Starting…";
195
+ el.classList.add("sweep-pay");
196
+ if (opts.variant === "outline")
197
+ el.classList.add("sweep-pay--outline");
198
+ if (opts.variant === "light")
199
+ el.classList.add("sweep-pay--light");
200
+ if (opts.size === "sm")
201
+ el.classList.add("sweep-pay--sm");
202
+ if (opts.block)
203
+ el.classList.add("sweep-pay--block");
204
+ // Our own constant markup, then the label by textContent — a merchant's
205
+ // string never reaches innerHTML.
206
+ el.innerHTML = COIN_SVG + SPINNER_SVG + '<span class="sweep-pay__label"></span>';
207
+ const labelEl = el.querySelector(".sweep-pay__label");
208
+ labelEl.textContent = label;
209
+ let busy = false;
210
+ const onClick = (event) => {
211
+ event.preventDefault();
212
+ // Covers the second click, and Enter on a button CSS has only made
213
+ // pointer-inert.
214
+ if (busy)
215
+ return;
216
+ busy = true;
217
+ el.setAttribute("aria-busy", "true");
218
+ labelEl.textContent = busyLabel;
219
+ void fetchUrl(opts)
220
+ .then((url) => {
221
+ if (opts.onRedirect && opts.onRedirect(url) === false) {
222
+ busy = false;
223
+ el.removeAttribute("aria-busy");
224
+ labelEl.textContent = label;
225
+ return;
226
+ }
227
+ // assign, not replace: the payer can come back with the back button.
228
+ window.location.assign(url);
229
+ })
230
+ .catch((e) => {
231
+ busy = false;
232
+ el.removeAttribute("aria-busy");
233
+ labelEl.textContent = label;
234
+ const error = e instanceof Error ? e : new Error(String(e));
235
+ if (opts.onError)
236
+ opts.onError(error);
237
+ else
238
+ console.error("[sweep] could not start the payment:", error);
239
+ });
240
+ };
241
+ el.addEventListener("click", onClick);
242
+ return {
243
+ element: el,
244
+ destroy() {
245
+ el.removeEventListener("click", onClick);
246
+ el.removeAttribute("aria-busy");
247
+ if (reuse) {
248
+ el.className = restore.className;
249
+ el.innerHTML = restore.html;
250
+ }
251
+ else {
252
+ el.remove();
253
+ }
254
+ },
255
+ };
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
+ }
449
+ /** The global the CDN build defines. Same functions, one name to reach them. */
450
+ const Sweep = { mount, authorize, openAuthorize, BUTTON_CSS, MODAL_CSS };
451
+
452
+ global.Sweep = Sweep;
453
+ })(typeof globalThis !== "undefined" ? globalThis : window);
package/package.json CHANGED
@@ -1,6 +1,51 @@
1
1
  {
2
2
  "name": "@sweepconsole/js",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Pay-with-USDC button for the browser \u2014 asks your server for a checkout URL and sends the payer there",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "default": "./dist/index.js"
13
+ },
14
+ "./button.css": "./dist/button.css"
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "README.md"
19
+ ],
20
+ "sideEffects": false,
21
+ "scripts": {
22
+ "build": "tsc -p tsconfig.json && node scripts/build-cdn.mjs",
23
+ "typecheck": "tsc -p tsconfig.json --noEmit",
24
+ "test": "node test/button.test.mjs",
25
+ "prepublishOnly": "pnpm build && pnpm test"
26
+ },
27
+ "keywords": [
28
+ "usdc",
29
+ "stablecoin",
30
+ "subscriptions",
31
+ "payments",
32
+ "checkout",
33
+ "button",
34
+ "sweep-console"
35
+ ],
36
+ "devDependencies": {
37
+ "typescript": "^5.6.0"
38
+ },
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/u4believe/sweep-console.git",
42
+ "directory": "packages/js"
43
+ },
44
+ "homepage": "https://github.com/u4believe/sweep-console#readme",
45
+ "bugs": {
46
+ "url": "https://github.com/u4believe/sweep-console/issues"
47
+ },
48
+ "publishConfig": {
49
+ "access": "public"
50
+ }
51
+ }