@sweepconsole/js 0.0.0-stage → 0.1.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 +21 -0
- package/README.md +137 -2
- package/dist/button.css +16 -0
- package/dist/index.d.ts +100 -0
- package/dist/index.js +255 -0
- package/dist/sweep.js +261 -0
- package/package.json +49 -4
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,138 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @sweepconsole/js
|
|
2
2
|
|
|
3
|
-
|
|
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.1.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
|
+
## Why it will not call Sweep for you
|
|
47
|
+
|
|
48
|
+
This package has no API key and cannot create a session. Everything a browser
|
|
49
|
+
sends is something the person at the browser can change, so an `externalRef`
|
|
50
|
+
that came from the page is a user id the customer chose — and on this rail a
|
|
51
|
+
mandate is *standing* authority, so a forged one misattributes a recurring
|
|
52
|
+
charge rather than one payment. Your server already knows who is logged in.
|
|
53
|
+
|
|
54
|
+
For a public link — a pricing page, an invoice, a QR code — there is no identity
|
|
55
|
+
to protect, and a plain anchor is enough:
|
|
56
|
+
|
|
57
|
+
```html
|
|
58
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@sweepconsole/js@0.1.0/dist/button.css">
|
|
59
|
+
<a class="sweep-pay" href="https://pay.sweepconsole.com/pay/plink_a1b2c3?ref=user_42">
|
|
60
|
+
Subscribe with USDC
|
|
61
|
+
</a>
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Install
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
npm i @sweepconsole/js
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
```js
|
|
71
|
+
import { mount } from "@sweepconsole/js";
|
|
72
|
+
import "@sweepconsole/js/button.css"; // or let mount() inject it
|
|
73
|
+
|
|
74
|
+
mount("#pay-usdc", { session: "/api/sweep/session" });
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Options
|
|
78
|
+
|
|
79
|
+
| Option | Default | |
|
|
80
|
+
| --- | --- | --- |
|
|
81
|
+
| `session` | *required* | Your endpoint, POSTed to, answering `{ url }`. A function returning the URL also works. |
|
|
82
|
+
| `label` | the element's own text, else `"Subscribe with USDC"` | |
|
|
83
|
+
| `busyLabel` | `"Starting…"` | |
|
|
84
|
+
| `variant` | `"solid"` | `"solid"`, `"outline"`, `"light"` |
|
|
85
|
+
| `size` | `"md"` | `"sm"` for a compact row |
|
|
86
|
+
| `block` | `false` | Full width, for a checkout column |
|
|
87
|
+
| `body` | — | Extra JSON for your endpoint |
|
|
88
|
+
| `headers` | — | Extra headers, e.g. a CSRF token |
|
|
89
|
+
| `credentials` | `"same-origin"` | So your session cookie is sent |
|
|
90
|
+
| `injectStyles` | `true` | Set `false` under a CSP that forbids inline styles and link `button.css` |
|
|
91
|
+
| `onError` | logs | The button returns to rest first, so the payer can retry. Tell them something. |
|
|
92
|
+
| `onRedirect` | — | Return `false` to handle the navigation yourself |
|
|
93
|
+
|
|
94
|
+
`mount()` returns `{ element, destroy() }`.
|
|
95
|
+
|
|
96
|
+
Your endpoint's reply is read liberally: `url`, `checkout_url`,
|
|
97
|
+
`authorization_url`, their camelCase forms, and any of them nested under `data`.
|
|
98
|
+
An endpoint that forwards Sweep's own response verbatim works unchanged.
|
|
99
|
+
|
|
100
|
+
## Theming
|
|
101
|
+
|
|
102
|
+
Four custom properties, and `font: inherit` so it never arrives in a typeface
|
|
103
|
+
you did not choose.
|
|
104
|
+
|
|
105
|
+
```css
|
|
106
|
+
.sweep-pay {
|
|
107
|
+
--sweep-radius: 10px;
|
|
108
|
+
--sweep-bg: #111827;
|
|
109
|
+
--sweep-fg: #fff;
|
|
110
|
+
--sweep-border: #111827;
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Say "Subscribe", not "Pay"
|
|
115
|
+
|
|
116
|
+
Authorizing on this rail signs a standing permission with a monthly ceiling, not
|
|
117
|
+
a single payment. A payer who read "Pay $29" and then meets a wallet asking for
|
|
118
|
+
recurring authority has been misled by the button, and that is the moment they
|
|
119
|
+
abandon the checkout. Use "Pay" only if you are charging once and never again.
|
|
120
|
+
|
|
121
|
+
## What it deliberately does not do
|
|
122
|
+
|
|
123
|
+
- **No iframe.** The hosted page needs a wallet, and injected providers are
|
|
124
|
+
unreliable inside a cross-origin frame. The button navigates the top level.
|
|
125
|
+
- **No wallet detection.** It never probes for `window.ethereum` to decide
|
|
126
|
+
whether to render. A mobile wallet browser fails that test, and the hosted
|
|
127
|
+
page handles a missing wallet better than a hidden button does.
|
|
128
|
+
- **No fulfilment signal.** Returning to your site means the payer came back,
|
|
129
|
+
nothing more. Grant access on `mandate.authorized`.
|
|
130
|
+
|
|
131
|
+
## Tests
|
|
132
|
+
|
|
133
|
+
```sh
|
|
134
|
+
node test/button.test.mjs
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Runs the built `dist/sweep.js` against a small DOM stub — the redirect, the
|
|
138
|
+
error path, the in-flight click guard, and style injection. Build first.
|
package/dist/button.css
ADDED
|
@@ -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}}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
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
|
+
/** The global the CDN build defines. Same functions, one name to reach them. */
|
|
97
|
+
export declare const Sweep: {
|
|
98
|
+
mount: typeof mount;
|
|
99
|
+
BUTTON_CSS: string;
|
|
100
|
+
};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
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
|
+
/** The global the CDN build defines. Same functions, one name to reach them. */
|
|
255
|
+
export const Sweep = { mount, BUTTON_CSS };
|
package/dist/sweep.js
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
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
|
+
/** The global the CDN build defines. Same functions, one name to reach them. */
|
|
258
|
+
const Sweep = { mount, BUTTON_CSS };
|
|
259
|
+
|
|
260
|
+
global.Sweep = Sweep;
|
|
261
|
+
})(typeof globalThis !== "undefined" ? globalThis : window);
|
package/package.json
CHANGED
|
@@ -1,6 +1,51 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sweepconsole/js",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.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
|
+
}
|