@three-ws/x402-payment-modal 1.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/CHANGELOG.md +57 -0
- package/LICENSE +180 -0
- package/README.md +347 -0
- package/TUTORIAL.md +188 -0
- package/dist/index.d.ts +141 -0
- package/dist/x402.js +1777 -0
- package/dist/x402.min.js +375 -0
- package/docs/api-reference.md +227 -0
- package/docs/architecture.md +171 -0
- package/docs/server-setup.md +239 -0
- package/docs/siwx.md +116 -0
- package/docs/spending-caps.md +92 -0
- package/docs/theming.md +124 -0
- package/examples/README.md +22 -0
- package/examples/plain-html/index.html +229 -0
- package/examples/react/README.md +69 -0
- package/examples/react/X402Button.jsx +84 -0
- package/examples/server-express/package.json +16 -0
- package/examples/server-express/public/index.html +89 -0
- package/examples/server-express/server.js +89 -0
- package/package.json +113 -0
- package/server/README.md +68 -0
- package/server/checkout.js +392 -0
- package/server/express.js +44 -0
- package/server/vercel.js +54 -0
- package/src/index.js +1776 -0
- package/types/index.d.ts +141 -0
- package/types/server.d.ts +109 -0
package/docs/theming.md
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# Theming
|
|
2
|
+
|
|
3
|
+
The modal ships its own styles so it looks finished out of the box, then gives
|
|
4
|
+
you clean hooks to skin it to your brand. There is no build step and no CSS file
|
|
5
|
+
to import — styles are injected at runtime.
|
|
6
|
+
|
|
7
|
+
For the markup these classes wrap, see the step model in
|
|
8
|
+
[architecture](./architecture.md).
|
|
9
|
+
|
|
10
|
+
## How styles are injected
|
|
11
|
+
|
|
12
|
+
On first use, the modal injects a single `<style id="x402-styles">` block into
|
|
13
|
+
the document head. It is injected **once**; subsequent opens reuse it.
|
|
14
|
+
|
|
15
|
+
To override it, do one of:
|
|
16
|
+
|
|
17
|
+
1. Define your own rules with **higher specificity** in a stylesheet loaded
|
|
18
|
+
**after** the module runs (so your rules win on equal specificity, or beat
|
|
19
|
+
the injected ones).
|
|
20
|
+
2. Use `!important` on the specific declarations you want to force.
|
|
21
|
+
3. Override the exposed **CSS custom property** (`--x402-z`) where one exists.
|
|
22
|
+
|
|
23
|
+
The modal is **light by default** and includes a built-in dark theme via
|
|
24
|
+
`@media (prefers-color-scheme: dark)` — it follows the OS setting automatically.
|
|
25
|
+
|
|
26
|
+
## CSS classes
|
|
27
|
+
|
|
28
|
+
| Class | Element / role |
|
|
29
|
+
|------------------------|---------------------------------------------------------------------|
|
|
30
|
+
| `.x402-overlay` | Root overlay. Holds `--x402-z` (z-index, default `2147483600`). |
|
|
31
|
+
| `.x402-modal` | The modal card container. |
|
|
32
|
+
| `.x402-head` | Header region (merchant/action/close). |
|
|
33
|
+
| `.x402-merchant` | Merchant block in the header. |
|
|
34
|
+
| `.x402-name` | Merchant name text. |
|
|
35
|
+
| `.x402-action` | Action label text (from `action`). |
|
|
36
|
+
| `.x402-close` | Close button. |
|
|
37
|
+
| `.x402-price-row` | Row holding price + network. |
|
|
38
|
+
| `.x402-price` | Numeric price. |
|
|
39
|
+
| `.x402-currency` | Currency label (e.g. USDC). |
|
|
40
|
+
| `.x402-network` | Network badge (e.g. Base / Solana). |
|
|
41
|
+
| `.x402-body` | Main content area. |
|
|
42
|
+
| `.x402-step` | A lifecycle step row. Modifiers below. |
|
|
43
|
+
| `.x402-step.x402-active` | The step currently in progress. |
|
|
44
|
+
| `.x402-step.x402-done` | A completed step. |
|
|
45
|
+
| `.x402-step.x402-error`| A step that failed. |
|
|
46
|
+
| `.x402-wallet-btn` | A wallet choice button (Phantom / EVM). |
|
|
47
|
+
| `.x402-pay-btn` | Primary pay / confirm button. |
|
|
48
|
+
| `.x402-pay-secondary` | Secondary action button (e.g. demoted pay under SIWX). |
|
|
49
|
+
| `.x402-error-box` | Error message container. |
|
|
50
|
+
| `.x402-receipt` | Settled-payment receipt block. |
|
|
51
|
+
| `.x402-result` | The paid endpoint's returned result. |
|
|
52
|
+
| `.x402-foot` | Footer (brand attribution + footer note). |
|
|
53
|
+
|
|
54
|
+
The four `.x402-step` rows correspond to the discover / connect / authorize /
|
|
55
|
+
verify lifecycle described in [architecture](./architecture.md).
|
|
56
|
+
|
|
57
|
+
## Overriding the z-index
|
|
58
|
+
|
|
59
|
+
The overlay z-index is a custom property so you can lower it under your own
|
|
60
|
+
top-layer UI (or raise it) without touching the rest of the stylesheet:
|
|
61
|
+
|
|
62
|
+
```css
|
|
63
|
+
.x402-overlay {
|
|
64
|
+
--x402-z: 9999;
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Default is `2147483600` (just under the 32-bit max) so the modal sits above most
|
|
69
|
+
app chrome.
|
|
70
|
+
|
|
71
|
+
## Dark mode
|
|
72
|
+
|
|
73
|
+
Dark styling is automatic via `prefers-color-scheme: dark`. To force a single
|
|
74
|
+
theme regardless of OS, override the relevant classes. For example, to force the
|
|
75
|
+
light look everywhere:
|
|
76
|
+
|
|
77
|
+
```css
|
|
78
|
+
@media (prefers-color-scheme: dark) {
|
|
79
|
+
.x402-modal { background: #ffffff; color: #111111; }
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Worked example: brand the pay button and modal radius
|
|
84
|
+
|
|
85
|
+
Load this **after** the module so it wins:
|
|
86
|
+
|
|
87
|
+
```html
|
|
88
|
+
<script type="module" src="https://unpkg.com/@three-ws/x402-payment-modal"></script>
|
|
89
|
+
<style>
|
|
90
|
+
/* Rounder card */
|
|
91
|
+
.x402-modal {
|
|
92
|
+
border-radius: 18px;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* Brand-colored primary button with a hover lift */
|
|
96
|
+
.x402-pay-btn {
|
|
97
|
+
background: #4f46e5; /* indigo */
|
|
98
|
+
color: #fff;
|
|
99
|
+
border: none;
|
|
100
|
+
transition: transform 120ms ease, background 120ms ease;
|
|
101
|
+
}
|
|
102
|
+
.x402-pay-btn:hover {
|
|
103
|
+
background: #4338ca;
|
|
104
|
+
transform: translateY(-1px);
|
|
105
|
+
}
|
|
106
|
+
.x402-pay-btn:active {
|
|
107
|
+
transform: translateY(0);
|
|
108
|
+
}
|
|
109
|
+
.x402-pay-btn:focus-visible {
|
|
110
|
+
outline: 2px solid #818cf8;
|
|
111
|
+
outline-offset: 2px;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/* Tone down the secondary action */
|
|
115
|
+
.x402-pay-secondary {
|
|
116
|
+
background: transparent;
|
|
117
|
+
color: #4f46e5;
|
|
118
|
+
}
|
|
119
|
+
</style>
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
If a rule still loses to the injected stylesheet, raise specificity (e.g.
|
|
123
|
+
`.x402-overlay .x402-pay-btn`) or add `!important` to the individual
|
|
124
|
+
declarations.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Examples
|
|
2
|
+
|
|
3
|
+
Runnable examples for [`@three-ws/x402-payment-modal`](https://www.npmjs.com/package/@three-ws/x402-payment-modal)
|
|
4
|
+
— a zero-dependency, vanilla-JS payment modal for any x402 paid HTTP endpoint.
|
|
5
|
+
|
|
6
|
+
| Example | What it shows | Fastest way to try it |
|
|
7
|
+
| --- | --- | --- |
|
|
8
|
+
| [`plain-html/`](./plain-html) | No build step. Declarative `data-x402-*` buttons, a programmatic `pay()` call, and `x402:result` / `x402:error` event handling — all from a single CDN `<script>`. | Open `plain-html/index.html` in a browser (or serve the folder, e.g. `npx serve plain-html`). |
|
|
9
|
+
| [`react/`](./react) | A reusable `<X402Button>` component that wraps `pay()` with a dynamic import, so it is SSR-safe. | Copy `react/X402Button.jsx` into your app — see [`react/README.md`](./react/README.md). |
|
|
10
|
+
| [`server-express/`](./server-express) | A complete Express server that mounts the Solana checkout router and serves a demo paid endpoint returning a real x402 v2 challenge. | `cd server-express && npm install && npm start`, then open http://localhost:3000 |
|
|
11
|
+
|
|
12
|
+
## Which do I need?
|
|
13
|
+
|
|
14
|
+
- **EVM payments** sign in the browser (EIP-3009) — the **plain-html** or
|
|
15
|
+
**react** client is all you need.
|
|
16
|
+
- **Solana payments** also require the **server-express** checkout endpoint to
|
|
17
|
+
build and settle the transfer. See
|
|
18
|
+
[`../docs/server-setup.md`](../docs/server-setup.md).
|
|
19
|
+
|
|
20
|
+
> The client examples point at placeholder endpoints
|
|
21
|
+
> (`https://api.example.com/...`). Swap in a real x402-enabled endpoint to take
|
|
22
|
+
> an actual payment.
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>x402 Payment Modal — Plain HTML demo</title>
|
|
7
|
+
|
|
8
|
+
<!--
|
|
9
|
+
─────────────────────────────────────────────────────────────────────────
|
|
10
|
+
DEMO ONLY
|
|
11
|
+
|
|
12
|
+
This page shows three ways to use @three-ws/x402-payment-modal with no
|
|
13
|
+
build step at all — just an ES module loaded from a CDN.
|
|
14
|
+
|
|
15
|
+
(a) Declarative: a button annotated with data-x402-* attributes. The
|
|
16
|
+
library auto-binds these on load — zero JavaScript required.
|
|
17
|
+
(b) Programmatic: a button wired in an inline module that imports pay().
|
|
18
|
+
(c) Events: x402:result / x402:error bubble from the clicked element;
|
|
19
|
+
we listen and render the outcome into #outcome below.
|
|
20
|
+
|
|
21
|
+
The endpoint below (https://api.example.com/...) is a PLACEHOLDER. To
|
|
22
|
+
actually take a payment, point data-x402-endpoint / pay({ endpoint }) at a
|
|
23
|
+
REAL x402-enabled HTTP endpoint that responds with HTTP 402 + an x402
|
|
24
|
+
challenge. The modal handles the 402 → connect wallet → sign → settle flow.
|
|
25
|
+
|
|
26
|
+
Self-hosting: instead of the unpkg URL you can serve the module yourself,
|
|
27
|
+
e.g. <script type="module" src="/vendor/x402.min.js" ...>. The data-x402-*
|
|
28
|
+
config attributes work the same way on a self-hosted <script> tag.
|
|
29
|
+
─────────────────────────────────────────────────────────────────────────
|
|
30
|
+
-->
|
|
31
|
+
|
|
32
|
+
<style>
|
|
33
|
+
:root {
|
|
34
|
+
--bg: #0b0c10;
|
|
35
|
+
--card: #15171e;
|
|
36
|
+
--border: #262a35;
|
|
37
|
+
--text: #e7e9ee;
|
|
38
|
+
--muted: #9aa1b1;
|
|
39
|
+
--accent: #6c8cff;
|
|
40
|
+
--accent-press: #5476f5;
|
|
41
|
+
--ok: #3ddc97;
|
|
42
|
+
--err: #ff6b6b;
|
|
43
|
+
}
|
|
44
|
+
* { box-sizing: border-box; }
|
|
45
|
+
html, body { height: 100%; }
|
|
46
|
+
body {
|
|
47
|
+
margin: 0;
|
|
48
|
+
background: radial-gradient(1200px 600px at 50% -10%, #1a1d27 0%, var(--bg) 60%);
|
|
49
|
+
color: var(--text);
|
|
50
|
+
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
51
|
+
display: grid;
|
|
52
|
+
place-items: center;
|
|
53
|
+
padding: 32px 16px;
|
|
54
|
+
}
|
|
55
|
+
.card {
|
|
56
|
+
width: 100%;
|
|
57
|
+
max-width: 520px;
|
|
58
|
+
background: var(--card);
|
|
59
|
+
border: 1px solid var(--border);
|
|
60
|
+
border-radius: 16px;
|
|
61
|
+
padding: 28px;
|
|
62
|
+
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.45);
|
|
63
|
+
}
|
|
64
|
+
h1 { font-size: 20px; margin: 0 0 4px; letter-spacing: -0.01em; }
|
|
65
|
+
p.lede { margin: 0 0 22px; color: var(--muted); }
|
|
66
|
+
h2 {
|
|
67
|
+
font-size: 12px;
|
|
68
|
+
text-transform: uppercase;
|
|
69
|
+
letter-spacing: 0.08em;
|
|
70
|
+
color: var(--muted);
|
|
71
|
+
margin: 22px 0 8px;
|
|
72
|
+
}
|
|
73
|
+
.row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
|
|
74
|
+
button {
|
|
75
|
+
appearance: none;
|
|
76
|
+
border: 0;
|
|
77
|
+
border-radius: 10px;
|
|
78
|
+
padding: 11px 18px;
|
|
79
|
+
font: inherit;
|
|
80
|
+
font-weight: 600;
|
|
81
|
+
color: #fff;
|
|
82
|
+
background: var(--accent);
|
|
83
|
+
cursor: pointer;
|
|
84
|
+
transition: transform 0.08s ease, background 0.15s ease, box-shadow 0.15s ease;
|
|
85
|
+
}
|
|
86
|
+
button:hover { background: var(--accent-press); }
|
|
87
|
+
button:active { transform: translateY(1px); }
|
|
88
|
+
button:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
|
|
89
|
+
button[disabled] { opacity: 0.55; cursor: progress; }
|
|
90
|
+
.hint { color: var(--muted); font-size: 13px; }
|
|
91
|
+
code {
|
|
92
|
+
background: #0c0e14;
|
|
93
|
+
border: 1px solid var(--border);
|
|
94
|
+
border-radius: 6px;
|
|
95
|
+
padding: 1px 6px;
|
|
96
|
+
font-size: 12px;
|
|
97
|
+
}
|
|
98
|
+
#outcome {
|
|
99
|
+
margin-top: 22px;
|
|
100
|
+
min-height: 44px;
|
|
101
|
+
border: 1px dashed var(--border);
|
|
102
|
+
border-radius: 12px;
|
|
103
|
+
padding: 14px 16px;
|
|
104
|
+
color: var(--muted);
|
|
105
|
+
font-size: 13px;
|
|
106
|
+
white-space: pre-wrap;
|
|
107
|
+
word-break: break-word;
|
|
108
|
+
transition: border-color 0.2s ease;
|
|
109
|
+
}
|
|
110
|
+
#outcome.ok { border-color: var(--ok); color: var(--ok); }
|
|
111
|
+
#outcome.err { border-color: var(--err); color: var(--err); }
|
|
112
|
+
.footer { margin-top: 20px; color: var(--muted); font-size: 12px; }
|
|
113
|
+
a { color: var(--accent); }
|
|
114
|
+
</style>
|
|
115
|
+
|
|
116
|
+
<!--
|
|
117
|
+
(a) Declarative usage.
|
|
118
|
+
|
|
119
|
+
Loading the module runs init() automatically: any element carrying a
|
|
120
|
+
data-x402-endpoint attribute becomes a pay trigger. Config that applies to
|
|
121
|
+
the whole page (brand, where the Solana checkout endpoint lives) rides on
|
|
122
|
+
THIS script tag as data-x402-* attributes.
|
|
123
|
+
-->
|
|
124
|
+
<script
|
|
125
|
+
type="module"
|
|
126
|
+
src="https://unpkg.com/@three-ws/x402-payment-modal"
|
|
127
|
+
data-x402-brand-name="Acme Summaries"
|
|
128
|
+
data-x402-brand-url="https://example.com"
|
|
129
|
+
data-x402-checkout-origin="https://example.com"
|
|
130
|
+
data-x402-footer-note="Payments settle in USDC. Powered by x402."
|
|
131
|
+
></script>
|
|
132
|
+
</head>
|
|
133
|
+
|
|
134
|
+
<body>
|
|
135
|
+
<main class="card">
|
|
136
|
+
<h1>x402 Payment Modal</h1>
|
|
137
|
+
<p class="lede">Pay any x402 endpoint in one click — no wallet integration code.</p>
|
|
138
|
+
|
|
139
|
+
<!-- (a) Zero-JS declarative button. The attributes describe the request. -->
|
|
140
|
+
<h2>Declarative (no JavaScript)</h2>
|
|
141
|
+
<div class="row">
|
|
142
|
+
<button
|
|
143
|
+
id="declarative-pay"
|
|
144
|
+
data-x402-endpoint="https://api.example.com/paid/summarize"
|
|
145
|
+
data-x402-method="POST"
|
|
146
|
+
data-x402-body='{"url":"https://en.wikipedia.org/wiki/x402"}'
|
|
147
|
+
data-x402-merchant="Acme Summaries"
|
|
148
|
+
data-x402-action="Summarize article"
|
|
149
|
+
>
|
|
150
|
+
Summarize for 0.01 USDC
|
|
151
|
+
</button>
|
|
152
|
+
<span class="hint">Driven entirely by <code>data-x402-*</code> attributes.</span>
|
|
153
|
+
</div>
|
|
154
|
+
|
|
155
|
+
<!-- (b) Programmatic button — wired by the inline module below. -->
|
|
156
|
+
<h2>Programmatic (inline module)</h2>
|
|
157
|
+
<div class="row">
|
|
158
|
+
<button id="programmatic-pay">Translate for 0.01 USDC</button>
|
|
159
|
+
<span class="hint">Calls <code>pay()</code> from a <code><script type="module"></code>.</span>
|
|
160
|
+
</div>
|
|
161
|
+
|
|
162
|
+
<!-- (c) Outcome surface — populated by the x402:result / x402:error listeners. -->
|
|
163
|
+
<h2>Outcome</h2>
|
|
164
|
+
<div id="outcome" role="status" aria-live="polite">No payment attempted yet.</div>
|
|
165
|
+
|
|
166
|
+
<p class="footer">
|
|
167
|
+
Endpoints above are placeholders — swap in a real x402 endpoint to take a payment.
|
|
168
|
+
You can also self-host the module instead of unpkg.
|
|
169
|
+
</p>
|
|
170
|
+
</main>
|
|
171
|
+
|
|
172
|
+
<script type="module">
|
|
173
|
+
// (b) Programmatic usage: import pay() straight from the CDN module.
|
|
174
|
+
// In a self-hosted setup, import from your own URL instead, e.g.
|
|
175
|
+
// import { pay } from '/vendor/x402.min.js';
|
|
176
|
+
import { pay } from 'https://unpkg.com/@three-ws/x402-payment-modal';
|
|
177
|
+
|
|
178
|
+
const button = document.getElementById('programmatic-pay');
|
|
179
|
+
|
|
180
|
+
button.addEventListener('click', async () => {
|
|
181
|
+
button.disabled = true;
|
|
182
|
+
const original = button.textContent;
|
|
183
|
+
button.textContent = 'Processing…';
|
|
184
|
+
try {
|
|
185
|
+
const result = await pay({
|
|
186
|
+
endpoint: 'https://api.example.com/paid/translate',
|
|
187
|
+
method: 'POST',
|
|
188
|
+
body: { text: 'Hello, world', to: 'es' },
|
|
189
|
+
merchant: 'Acme Translate',
|
|
190
|
+
action: 'Translate text',
|
|
191
|
+
});
|
|
192
|
+
// result = { ok, result, payment?, siwx?, response }
|
|
193
|
+
console.log('Payment result:', result);
|
|
194
|
+
} catch (err) {
|
|
195
|
+
// The modal rejects with err.code === 'cancelled' when dismissed.
|
|
196
|
+
if (err && err.code === 'cancelled') {
|
|
197
|
+
console.log('User cancelled the payment.');
|
|
198
|
+
} else {
|
|
199
|
+
console.error('Payment failed:', err);
|
|
200
|
+
}
|
|
201
|
+
} finally {
|
|
202
|
+
button.disabled = false;
|
|
203
|
+
button.textContent = original;
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// (c) Event listeners. x402:result and x402:error bubble from the element
|
|
208
|
+
// that triggered the payment, so a single document-level listener covers
|
|
209
|
+
// both the declarative and programmatic buttons.
|
|
210
|
+
const outcome = document.getElementById('outcome');
|
|
211
|
+
|
|
212
|
+
document.addEventListener('x402:result', (event) => {
|
|
213
|
+
const detail = event.detail; // PayResult: { ok, result, payment?, siwx?, response }
|
|
214
|
+
outcome.className = 'ok';
|
|
215
|
+
const payer = detail.payment?.payer ? `\nPayer: ${detail.payment.payer}` : '';
|
|
216
|
+
const tx = detail.payment?.transaction ? `\nTx: ${detail.payment.transaction}` : '';
|
|
217
|
+
outcome.textContent = `Paid ✓ (${detail.payment?.network ?? 'settled'})${payer}${tx}`;
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// detail.error is a string. Note: dismissing the modal does NOT fire this
|
|
221
|
+
// event — the auto-binder swallows the cancellation — so anything here is a
|
|
222
|
+
// genuine failure.
|
|
223
|
+
document.addEventListener('x402:error', (event) => {
|
|
224
|
+
outcome.className = 'err';
|
|
225
|
+
outcome.textContent = `Payment failed: ${event.detail?.error ?? 'unknown error'}`;
|
|
226
|
+
});
|
|
227
|
+
</script>
|
|
228
|
+
</body>
|
|
229
|
+
</html>
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# React example — `<X402Button>`
|
|
2
|
+
|
|
3
|
+
A drop-in React component that wraps `pay()` from
|
|
4
|
+
[`@three-ws/x402-payment-modal`](https://www.npmjs.com/package/@three-ws/x402-payment-modal).
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npm i @three-ws/x402-payment-modal
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Use it
|
|
13
|
+
|
|
14
|
+
Copy [`X402Button.jsx`](./X402Button.jsx) into your project, then:
|
|
15
|
+
|
|
16
|
+
```jsx
|
|
17
|
+
import X402Button from './X402Button';
|
|
18
|
+
|
|
19
|
+
export default function Demo() {
|
|
20
|
+
return (
|
|
21
|
+
<X402Button
|
|
22
|
+
endpoint="https://api.example.com/paid/summarize"
|
|
23
|
+
method="POST"
|
|
24
|
+
body={{ url: 'https://en.wikipedia.org/wiki/x402' }}
|
|
25
|
+
merchant="Acme Summaries"
|
|
26
|
+
action="Summarize article"
|
|
27
|
+
label="Summarize for 0.01 USDC"
|
|
28
|
+
onResult={(r) => console.log('paid', r.payment)}
|
|
29
|
+
onError={(e) => console.error('payment failed', e)}
|
|
30
|
+
/>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### Props
|
|
36
|
+
|
|
37
|
+
| Prop | Type | Notes |
|
|
38
|
+
| ---------- | ---------- | ---------------------------------------------------------- |
|
|
39
|
+
| `endpoint` | `string` | Required. The x402-enabled HTTP endpoint to call. |
|
|
40
|
+
| `method` | `string` | HTTP method (default `GET`). |
|
|
41
|
+
| `body` | `object` | Request body (sent as JSON). |
|
|
42
|
+
| `merchant` | `string` | Display name shown in the modal. |
|
|
43
|
+
| `action` | `string` | Short description of what the user is paying for. |
|
|
44
|
+
| `label` | `string` | Button text (default `Pay`). `children` overrides it. |
|
|
45
|
+
| `onResult` | `function` | Called with the `PayResult` on success. |
|
|
46
|
+
| `onError` | `function` | Called on failure. **Not** called when the user cancels. |
|
|
47
|
+
|
|
48
|
+
`onResult` receives `{ ok, result, payment?, siwx?, response }`.
|
|
49
|
+
|
|
50
|
+
## SSR safety
|
|
51
|
+
|
|
52
|
+
The package is browser-only (it renders a modal and connects to a wallet), so
|
|
53
|
+
`X402Button` imports it lazily **inside the click handler**:
|
|
54
|
+
|
|
55
|
+
```js
|
|
56
|
+
const { pay } = await import('@three-ws/x402-payment-modal');
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Nothing from the package runs during render or on the server, so the component
|
|
60
|
+
is safe in Next.js, Remix, and other SSR frameworks without `dynamic`/`ssr:false`
|
|
61
|
+
wrappers.
|
|
62
|
+
|
|
63
|
+
## Solana payments need a server
|
|
64
|
+
|
|
65
|
+
EVM payments sign in the browser (EIP-3009) and need no backend. **Solana
|
|
66
|
+
payments require a checkout endpoint** that builds and settles the transfer.
|
|
67
|
+
Stand one up before going live — see
|
|
68
|
+
[`../../docs/server-setup.md`](../../docs/server-setup.md) and the runnable
|
|
69
|
+
[`examples/server-express`](../server-express) server.
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <X402Button> — a thin React wrapper around @three-ws/x402-payment-modal.
|
|
3
|
+
*
|
|
4
|
+
* The package is a browser-only ES module (it renders a modal and talks to a
|
|
5
|
+
* wallet), so we dynamically import it INSIDE the click handler. That keeps the
|
|
6
|
+
* component SSR-safe: nothing from the package is touched during render or on
|
|
7
|
+
* the server.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
*
|
|
11
|
+
* import X402Button from './X402Button';
|
|
12
|
+
*
|
|
13
|
+
* <X402Button
|
|
14
|
+
* endpoint="https://api.example.com/paid/summarize"
|
|
15
|
+
* method="POST"
|
|
16
|
+
* body={{ url: 'https://en.wikipedia.org/wiki/x402' }}
|
|
17
|
+
* merchant="Acme Summaries"
|
|
18
|
+
* action="Summarize article"
|
|
19
|
+
* label="Summarize for 0.01 USDC"
|
|
20
|
+
* onResult={(r) => console.log('paid', r)}
|
|
21
|
+
* onError={(e) => console.error(e)}
|
|
22
|
+
* />
|
|
23
|
+
*
|
|
24
|
+
* For Solana payments your app must also run the server checkout endpoint
|
|
25
|
+
* (see ../../docs/server-setup.md and examples/server-express). EVM payments
|
|
26
|
+
* sign in-browser and need no server.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { useCallback, useState } from 'react';
|
|
30
|
+
|
|
31
|
+
export default function X402Button({
|
|
32
|
+
endpoint,
|
|
33
|
+
method = 'GET',
|
|
34
|
+
body,
|
|
35
|
+
merchant,
|
|
36
|
+
action,
|
|
37
|
+
label = 'Pay',
|
|
38
|
+
onResult,
|
|
39
|
+
onError,
|
|
40
|
+
children,
|
|
41
|
+
...rest
|
|
42
|
+
}) {
|
|
43
|
+
const [processing, setProcessing] = useState(false);
|
|
44
|
+
|
|
45
|
+
const handleClick = useCallback(async () => {
|
|
46
|
+
if (processing) return;
|
|
47
|
+
setProcessing(true);
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
// Dynamic import keeps the browser-only module out of the SSR bundle.
|
|
51
|
+
const { pay } = await import('@three-ws/x402-payment-modal');
|
|
52
|
+
|
|
53
|
+
const result = await pay({
|
|
54
|
+
endpoint,
|
|
55
|
+
method,
|
|
56
|
+
body,
|
|
57
|
+
merchant,
|
|
58
|
+
action,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// result = { ok, result, payment?, siwx?, response }
|
|
62
|
+
onResult?.(result);
|
|
63
|
+
} catch (err) {
|
|
64
|
+
// The modal rejects with err.code === 'cancelled' when the user dismisses
|
|
65
|
+
// it. That is not an error — stay silent so we don't flash a failure.
|
|
66
|
+
if (err && err.code === 'cancelled') return;
|
|
67
|
+
onError?.(err);
|
|
68
|
+
} finally {
|
|
69
|
+
setProcessing(false);
|
|
70
|
+
}
|
|
71
|
+
}, [processing, endpoint, method, body, merchant, action, onResult, onError]);
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<button
|
|
75
|
+
type="button"
|
|
76
|
+
onClick={handleClick}
|
|
77
|
+
disabled={processing}
|
|
78
|
+
aria-busy={processing}
|
|
79
|
+
{...rest}
|
|
80
|
+
>
|
|
81
|
+
{processing ? 'Processing…' : children ?? label}
|
|
82
|
+
</button>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "x402-checkout-example",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"description": "Runnable Express server that mounts the @three-ws/x402-payment-modal Solana checkout router and serves a demo paid endpoint.",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"start": "node server.js"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@solana/spl-token": "*",
|
|
12
|
+
"@solana/web3.js": "*",
|
|
13
|
+
"@three-ws/x402-payment-modal": "*",
|
|
14
|
+
"express": "*"
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>x402 Express checkout demo</title>
|
|
7
|
+
<style>
|
|
8
|
+
body {
|
|
9
|
+
margin: 0;
|
|
10
|
+
min-height: 100vh;
|
|
11
|
+
display: grid;
|
|
12
|
+
place-items: center;
|
|
13
|
+
background: #0b0c10;
|
|
14
|
+
color: #e7e9ee;
|
|
15
|
+
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
16
|
+
}
|
|
17
|
+
.card {
|
|
18
|
+
background: #15171e;
|
|
19
|
+
border: 1px solid #262a35;
|
|
20
|
+
border-radius: 16px;
|
|
21
|
+
padding: 28px;
|
|
22
|
+
max-width: 460px;
|
|
23
|
+
text-align: center;
|
|
24
|
+
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.45);
|
|
25
|
+
}
|
|
26
|
+
h1 { font-size: 20px; margin: 0 0 6px; }
|
|
27
|
+
p { color: #9aa1b1; margin: 0 0 20px; }
|
|
28
|
+
button {
|
|
29
|
+
border: 0;
|
|
30
|
+
border-radius: 10px;
|
|
31
|
+
padding: 11px 18px;
|
|
32
|
+
font: inherit;
|
|
33
|
+
font-weight: 600;
|
|
34
|
+
color: #fff;
|
|
35
|
+
background: #6c8cff;
|
|
36
|
+
cursor: pointer;
|
|
37
|
+
}
|
|
38
|
+
button:hover { background: #5476f5; }
|
|
39
|
+
button[disabled] { opacity: 0.55; cursor: progress; }
|
|
40
|
+
#out {
|
|
41
|
+
margin-top: 18px;
|
|
42
|
+
min-height: 22px;
|
|
43
|
+
font-size: 13px;
|
|
44
|
+
color: #9aa1b1;
|
|
45
|
+
white-space: pre-wrap;
|
|
46
|
+
word-break: break-word;
|
|
47
|
+
}
|
|
48
|
+
</style>
|
|
49
|
+
</head>
|
|
50
|
+
<body>
|
|
51
|
+
<main class="card">
|
|
52
|
+
<h1>x402 Express checkout demo</h1>
|
|
53
|
+
<p>Click to pay the local <code>/api/paid/hello</code> endpoint in USDC.</p>
|
|
54
|
+
<button id="pay">Say hello for 0.01 USDC</button>
|
|
55
|
+
<div id="out" role="status" aria-live="polite"></div>
|
|
56
|
+
</main>
|
|
57
|
+
|
|
58
|
+
<script type="module">
|
|
59
|
+
// Loaded from the CDN for the demo; bundle it locally for production.
|
|
60
|
+
import { pay, configure } from 'https://unpkg.com/@three-ws/x402-payment-modal';
|
|
61
|
+
|
|
62
|
+
// Tell the modal where this server's Solana checkout endpoint lives.
|
|
63
|
+
configure({
|
|
64
|
+
checkoutOrigin: window.location.origin,
|
|
65
|
+
checkoutPath: '/api/x402-checkout',
|
|
66
|
+
brand: { name: 'x402 Express demo', url: window.location.origin },
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const out = document.getElementById('out');
|
|
70
|
+
document.getElementById('pay').addEventListener('click', async (e) => {
|
|
71
|
+
e.target.disabled = true;
|
|
72
|
+
try {
|
|
73
|
+
const result = await pay({
|
|
74
|
+
endpoint: '/api/paid/hello',
|
|
75
|
+
merchant: 'x402 Express demo',
|
|
76
|
+
action: 'Say hello',
|
|
77
|
+
});
|
|
78
|
+
out.textContent = `Paid ✓ ${JSON.stringify(result.result ?? result.ok)}`;
|
|
79
|
+
} catch (err) {
|
|
80
|
+
out.textContent = err?.code === 'cancelled'
|
|
81
|
+
? 'Cancelled.'
|
|
82
|
+
: `Failed: ${err?.message ?? err}`;
|
|
83
|
+
} finally {
|
|
84
|
+
e.target.disabled = false;
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
</script>
|
|
88
|
+
</body>
|
|
89
|
+
</html>
|