@mesub/react 0.0.0-stage → 0.1.2
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 +340 -2
- package/dist/index.cjs +2974 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +152 -0
- package/dist/index.d.ts +152 -0
- package/dist/index.js +2955 -0
- package/dist/index.js.map +1 -0
- package/package.json +99 -4
- package/styles.css +2764 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mesub
|
|
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,341 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @mesub/react
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The browser side of [Mesub](https://mesub.io), recurring payments on Solana: a
|
|
4
|
+
Subscribe button and a list of subscriptions that handle the wallet for you.
|
|
5
|
+
|
|
6
|
+
It is a complement to [`@mesub/node`](https://github.com/Mesub-io/node-sdk),
|
|
7
|
+
which does everything on its own. This package only spares you the wallet
|
|
8
|
+
part in the browser: picking one, connecting it, signing in the right order.
|
|
9
|
+
|
|
10
|
+
- It **never talks to Mesub** and holds **no key**. It calls your own server,
|
|
11
|
+
on the routes `@mesub/node` mounts there.
|
|
12
|
+
- Your customer has **no Mesub account**. Who they are comes from your own
|
|
13
|
+
login: there is no sign-in here, no session, no token.
|
|
14
|
+
- It is **not a wallet connector** for your site. It asks a wallet for an
|
|
15
|
+
account when it needs a signature, and never disconnects one.
|
|
16
|
+
|
|
17
|
+
**[Read the docs](https://docs.mesub.io/docs/react)** for the walkthrough.
|
|
18
|
+
|
|
19
|
+
[](https://www.npmjs.com/package/@mesub/react)
|
|
20
|
+
|
|
21
|
+
> **Status: early, 0.x.** The API may still change between minor versions:
|
|
22
|
+
> `npm update` takes patches (0.1.x), a new minor (0.2.0) takes
|
|
23
|
+
> `npm install @mesub/react@latest`. See the
|
|
24
|
+
> [board](https://github.com/orgs/Mesub-io/projects/5) for what is next.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
npm install @mesub/react
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`react` and `react-dom` 18 or later are peer dependencies. Every release is
|
|
33
|
+
built by GitHub Actions from a tagged commit and published with a provenance
|
|
34
|
+
statement: `npm audit signatures` verifies it.
|
|
35
|
+
|
|
36
|
+
## Requirements
|
|
37
|
+
|
|
38
|
+
React 18 or 19. Works with Next.js's App Router: the build starts with
|
|
39
|
+
`'use client'`. Nothing Solana to install on your side.
|
|
40
|
+
|
|
41
|
+
On your server, `@mesub/node` with its routes mounted (`mesubRoutes` or
|
|
42
|
+
`mesubRouteHandlers`): `@mesub/react` 0.1.x works with the routes of
|
|
43
|
+
`@mesub/node` 0.1.x. Upgrade both to the same minor together.
|
|
44
|
+
|
|
45
|
+
## One line on each side
|
|
46
|
+
|
|
47
|
+
On your server, after your own login
|
|
48
|
+
([details](https://github.com/Mesub-io/node-sdk#routes-for-the-react-widget)):
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
import { mesubRoutes } from '@mesub/node/express';
|
|
52
|
+
|
|
53
|
+
app.use(
|
|
54
|
+
'/api/mesub',
|
|
55
|
+
mesubRoutes({ customer: (req) => (req.user ? { external_id: req.user.id } : null) }),
|
|
56
|
+
);
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
In your app, once:
|
|
60
|
+
|
|
61
|
+
```tsx
|
|
62
|
+
import { MesubProvider } from '@mesub/react';
|
|
63
|
+
import '@mesub/react/styles.css';
|
|
64
|
+
|
|
65
|
+
export function App({ children }) {
|
|
66
|
+
return <MesubProvider endpoint="/api/mesub">{children}</MesubProvider>;
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
| Prop | What it is |
|
|
71
|
+
| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
|
72
|
+
| `endpoint` | Where you mounted the routes. A path on your site, or a full URL. |
|
|
73
|
+
| `chain` | The network the wallet signs for: `solana:mainnet` (the default, where Mesub runs). It must match your plans' `network`. |
|
|
74
|
+
| `fetch` | Your own `(url, init) => Promise<Response>`, to add headers or credentials. |
|
|
75
|
+
| `theme` | `light`, `dark` or `auto`, set on the widget as `data-mesub-theme`. |
|
|
76
|
+
| `manageUrl` | Where "Cancel any time" leads once subscribed: your own page (`/account`, or a full URL). Mesub's page by default; `null` for no button. |
|
|
77
|
+
|
|
78
|
+
Requests go with `credentials: 'include'`, so your session cookie travels. If
|
|
79
|
+
your login is a bearer token instead, add it in `fetch`:
|
|
80
|
+
|
|
81
|
+
```tsx
|
|
82
|
+
<MesubProvider
|
|
83
|
+
endpoint="/api/mesub"
|
|
84
|
+
fetch={(url, init) =>
|
|
85
|
+
fetch(url, { ...init, headers: { ...init.headers, Authorization: `Bearer ${token}` } })
|
|
86
|
+
}
|
|
87
|
+
>
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Subscribe
|
|
91
|
+
|
|
92
|
+
```tsx
|
|
93
|
+
import { SubscribeButton } from '@mesub/react';
|
|
94
|
+
|
|
95
|
+
<SubscribeButton plan="pro" onSubscribed={(subscription) => unlock(subscription)}>
|
|
96
|
+
Subscribe to Pro
|
|
97
|
+
</SubscribeButton>;
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`plan` is the plan's slug, as set in the Mesub dashboard. The click opens one
|
|
101
|
+
dialog, from the plan to the receipt:
|
|
102
|
+
|
|
103
|
+
1. **The plan**: its name, price and period, from `GET /plans/:slug`, and its end
|
|
104
|
+
when it has one. A plan ends on any second, so its end is always written
|
|
105
|
+
with its time, in UTC, the same for every subscriber: `Oct 20, 2026, 23:59 UTC`.
|
|
106
|
+
A plan that ends before a second charge can run is priced `once`, with the
|
|
107
|
+
end its access runs to.
|
|
108
|
+
2. **A wallet**: the installed wallets that can sign
|
|
109
|
+
([Wallet Standard](https://github.com/wallet-standard/wallet-standard), so
|
|
110
|
+
Phantom, Solflare and the others). The one picked is asked for its account.
|
|
111
|
+
3. **The review**: your server prepares the subscription
|
|
112
|
+
(`POST /subscriptions`), and the dialog shows what it costs before the wallet
|
|
113
|
+
is asked anything: the price, the next charge, the paying wallet, the deposit
|
|
114
|
+
returned when the subscription is closed, the network fee, and the terms.
|
|
115
|
+
From here to the receipt, the terms decide whether it is a single charge
|
|
116
|
+
(`Amount: 2 USDC, a single charge`): no next charge is shown then, only the
|
|
117
|
+
end of the access.
|
|
118
|
+
4. **Two approvals**: the wallet signs the terms, a message
|
|
119
|
+
(`solana:signMessage`), then the transaction, without sending it
|
|
120
|
+
(`solana:signTransaction`). A transaction lives about half a minute, so a
|
|
121
|
+
fresh one is asked for once the terms are signed
|
|
122
|
+
(`POST /subscriptions/:id/transaction`), and that one is signed. If it names
|
|
123
|
+
new terms, they are shown and signed again. A server whose `@mesub/node`
|
|
124
|
+
predates the route answers 404: the transaction of the review is signed.
|
|
125
|
+
5. **Confirming**: both go to `POST /subscriptions/:id/submit`. Mesub co-signs
|
|
126
|
+
and sends the transaction, and `onSubscribed` fires once it landed.
|
|
127
|
+
A transaction that expired before it was sent (`transaction_expired`, as an
|
|
128
|
+
error code or as the submit's `reason_code`) is replaced once, for the same
|
|
129
|
+
subscription, and signed again; a second expiry in a row stops with "Nothing
|
|
130
|
+
was charged." One the wallet held past its `transaction_expires_at` is
|
|
131
|
+
replaced the same way before it is submitted, the time left counted on your
|
|
132
|
+
server's clock (its `Date` header) when the browser can read it.
|
|
133
|
+
|
|
134
|
+
The button is a `<button>` with `data-mesub-subscribe` and `data-mesub-state`
|
|
135
|
+
(`idle`, `open`, `signing`, `confirming` or `subscribed`). `children` is its
|
|
136
|
+
label, every other button prop goes to the button, and an `onClick` that calls
|
|
137
|
+
`preventDefault()` stops the flow. Once subscribed it is followed by
|
|
138
|
+
`<span data-mesub-receipt>`.
|
|
139
|
+
|
|
140
|
+
For a button of your own, `useSubscribe` opens the same dialog:
|
|
141
|
+
|
|
142
|
+
```tsx
|
|
143
|
+
import { useSubscribe } from '@mesub/react';
|
|
144
|
+
|
|
145
|
+
function Buy() {
|
|
146
|
+
const { state, subscribe } = useSubscribe('pro', { onSubscribed: unlock });
|
|
147
|
+
return (
|
|
148
|
+
<button disabled={state !== 'idle'} onClick={() => void subscribe()}>
|
|
149
|
+
{state === 'subscribed' ? 'Thanks!' : 'Buy'}
|
|
150
|
+
</button>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
`subscribe()` resolves when the dialog closes: with the subscription, or null
|
|
156
|
+
when it did not go through. The hook also gives `subscription` and
|
|
157
|
+
`signature`, the transaction's id when the wallet is its first signer.
|
|
158
|
+
|
|
159
|
+
## Manage
|
|
160
|
+
|
|
161
|
+
```tsx
|
|
162
|
+
import { ManageButton } from '@mesub/react';
|
|
163
|
+
|
|
164
|
+
<ManageButton plan="pro" />;
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
A button, like `SubscribeButton`: it opens the customer's subscription to that
|
|
168
|
+
plan in the Mesub window. One subscription, not a list: how it stands, what is
|
|
169
|
+
charged next, the latest payments with their receipts, and the one thing it
|
|
170
|
+
allows now (cancel, resume or close). Leave `plan` out to open the customer's
|
|
171
|
+
live subscription whatever its plan. Pass children to word the button your way.
|
|
172
|
+
|
|
173
|
+
The payments and the next charge come from `GET /subscriptions/:id` of the
|
|
174
|
+
routes; on a server that predates it, the window stands without them.
|
|
175
|
+
|
|
176
|
+
To list every subscription in a page of yours instead:
|
|
177
|
+
|
|
178
|
+
```tsx
|
|
179
|
+
import { ManageSubscriptions } from '@mesub/react';
|
|
180
|
+
|
|
181
|
+
<ManageSubscriptions onChanged={(subscription) => refresh(subscription)} />;
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
The signed-in customer's subscriptions, from `GET /subscriptions`: each with
|
|
185
|
+
its plan, its status, its next charge or the end of its access, the wallet that
|
|
186
|
+
pays it, and the one thing it allows now. A subscription in the last period of
|
|
187
|
+
a plan that ends has no next charge: it shows the end of its access instead.
|
|
188
|
+
|
|
189
|
+
| The subscription is | It allows |
|
|
190
|
+
| ---------------------------------------------------------------------- | ---------- |
|
|
191
|
+
| running (`active`, `unpaid`, `stopped`) | **Cancel** |
|
|
192
|
+
| `cancelled`, with access still running | **Resume** |
|
|
193
|
+
| `closable: true` (ended, cancelled and past its end, stopped for good) | **Close** |
|
|
194
|
+
|
|
195
|
+
Close follows `closable`, as Mesub last read the chain. Until then, a card
|
|
196
|
+
shows **Close from** and the date of `closable_from`, and offers Close once
|
|
197
|
+
that date passes with the page open. A cancellation with a payment missed has
|
|
198
|
+
no access and cannot be resumed: it offers nothing until it can be closed. A
|
|
199
|
+
server that predates `closable` is read by the status and the dates, as before.
|
|
200
|
+
A `stopped` one says why from `stop_reason`: every retry failed, the date to pay
|
|
201
|
+
by passed, or the wallet's approval was closed for good (that one is closed, not
|
|
202
|
+
cancelled).
|
|
203
|
+
|
|
204
|
+
Each opens a dialog that says what it does, then runs two steps: your server
|
|
205
|
+
builds a transaction (`POST /subscriptions/:id/cancel`, `/resume` or `/close`),
|
|
206
|
+
the wallet **signs and sends it itself** (`solana:signAndSendTransaction`), and
|
|
207
|
+
its signature goes to the matching `/confirm`. The list is read again after.
|
|
208
|
+
|
|
209
|
+
A late one (`unpaid`) also shows **Pay now** when paying can succeed: the
|
|
210
|
+
customer pays the missed period at once, with nothing to sign, through
|
|
211
|
+
`POST /subscriptions/:id/retry` of the routes (`subscriptions.retry` of
|
|
212
|
+
`@mesub/node`). Not on a paused one, on a wallet that no longer approves it,
|
|
213
|
+
nor past its date to pay by. What came of it is said under the button: the
|
|
214
|
+
payment on its way, or why not (a short wallet spends no retry). On Free,
|
|
215
|
+
where Mesub does not try again on its own, the card shows that date to pay by.
|
|
216
|
+
|
|
217
|
+
One whose charges are held back for a cause that is not the customer's
|
|
218
|
+
(`blocked_since`) says so in one line: payments are on hold, nothing is asked
|
|
219
|
+
of them, and until when their access runs. The cause is never named to them,
|
|
220
|
+
and **Pay now** is not offered meanwhile.
|
|
221
|
+
|
|
222
|
+
Only the wallet that pays a subscription can sign for it. One already
|
|
223
|
+
connected to your site is found without a prompt; otherwise the customer picks
|
|
224
|
+
it. Closing returns the deposit to that wallet.
|
|
225
|
+
|
|
226
|
+
For a list of your own, `useSubscriptions` gives the same:
|
|
227
|
+
|
|
228
|
+
```tsx
|
|
229
|
+
import { useSubscriptions } from '@mesub/react';
|
|
230
|
+
|
|
231
|
+
function Mine() {
|
|
232
|
+
const { state, subscriptions, error, reload, manage } = useSubscriptions();
|
|
233
|
+
|
|
234
|
+
if (state === 'signed-out') return <a href="/login">Sign in</a>;
|
|
235
|
+
return subscriptions.map((held) => (
|
|
236
|
+
<button key={held.id} disabled={!held.action} onClick={() => void manage(held.id)}>
|
|
237
|
+
{held.plan}: {held.status}, {held.action ?? 'nothing to do'}
|
|
238
|
+
</button>
|
|
239
|
+
));
|
|
240
|
+
}
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
- `state` is `loading`, `ready`, `signed-out` or `error` (with `error`, the
|
|
244
|
+
server's message).
|
|
245
|
+
- `subscriptions` are newest first, each with `action`: `cancel`, `resume`,
|
|
246
|
+
`close` or null, and `payable`: whether **Pay now** can succeed. Checkouts
|
|
247
|
+
nobody signed are left out.
|
|
248
|
+
- `payNow(id)` pays that late payment now and resolves `{ ok, message }`, the
|
|
249
|
+
words to show; it never throws.
|
|
250
|
+
- `manage(id)` opens the dialog for that action and resolves when it closes:
|
|
251
|
+
the subscription as it is now, or null if nothing changed.
|
|
252
|
+
- Every list reads again when a subscription is made or changed through the
|
|
253
|
+
widget; `reload()` does it on demand.
|
|
254
|
+
|
|
255
|
+
Fields are as `@mesub/node` serves them: snake case, dates as ISO strings.
|
|
256
|
+
|
|
257
|
+
## The states to know
|
|
258
|
+
|
|
259
|
+
Each one has its own screen, and says whether anything moved: "Nothing was
|
|
260
|
+
charged." (or "Nothing changed.") only when nothing can have landed.
|
|
261
|
+
|
|
262
|
+
- **Nobody signed in on your site** (your routes answer 401): the dialog says
|
|
263
|
+
to sign in first and offers nothing to sign; the list says to sign in.
|
|
264
|
+
- **No wallet installed**: links to Phantom and Solflare, and Reload.
|
|
265
|
+
- **A wallet that cannot sign a message, or a transaction without sending
|
|
266
|
+
it**: left out of the list, and named with what it lacks.
|
|
267
|
+
- **The wallet refuses** to connect, to sign the terms or to sign the
|
|
268
|
+
transaction: nothing is submitted. Try again asks your server anew and shows
|
|
269
|
+
the review again.
|
|
270
|
+
- **The terms expired** (`expires_at` passed, before or between the two
|
|
271
|
+
signatures): your server is asked again and the review comes back with fresh
|
|
272
|
+
terms. Stale terms are never signed nor submitted.
|
|
273
|
+
- **The wrong wallet** for a subscription being managed: it says which account
|
|
274
|
+
the wallet is on and which one pays. Nothing is built or signed.
|
|
275
|
+
- **Nothing settled** (`reason` on submit or on a confirm): the widget branches
|
|
276
|
+
on `reason_code`, never on the sentence. `transaction_expired` asks for a
|
|
277
|
+
fresh transaction, as above, on submit, and builds a new one on a confirm.
|
|
278
|
+
`nothing_landed` on submit, or `cancellation_not_landed`,
|
|
279
|
+
`resume_not_landed` and `close_not_landed` on a confirm: the transaction may
|
|
280
|
+
still land, so it says so and offers Check again, which sends nothing new.
|
|
281
|
+
Any other code: Mesub's reason, as written, and Try again.
|
|
282
|
+
- **Mesub refuses** (409: already subscribed, not enough to pay the first
|
|
283
|
+
period, plan closed...): its message, as written. A 403 `wallet_mismatch`
|
|
284
|
+
asks for another wallet, a 429 says how long to wait.
|
|
285
|
+
- **The network fails or times out**: 15 seconds on reads and builds, with Try
|
|
286
|
+
again. Submit and the confirms wait 90 seconds for the chain; past that, or
|
|
287
|
+
on a 5xx, the outcome is unknown: "Payment may be pending" and Check again,
|
|
288
|
+
which sends the same request and never a second payment.
|
|
289
|
+
- **Mesub is busy** (a 503 `unavailable` with `Retry-After`): on submit, too
|
|
290
|
+
many at once and nothing was signed or sent, so the widget waits (2 seconds,
|
|
291
|
+
10 at most) and sends the same request once more; still busy, it says how
|
|
292
|
+
long to wait and offers Check again. **Pay now** refused that way holds its
|
|
293
|
+
button with a countdown (about 20 seconds), says nothing was charged, then
|
|
294
|
+
can be pressed again; preparing says when to try again.
|
|
295
|
+
- **Double clicks** open one dialog, prepare one subscription and ask the
|
|
296
|
+
wallet once.
|
|
297
|
+
|
|
298
|
+
## Theming
|
|
299
|
+
|
|
300
|
+
`@mesub/react/styles.css` styles the widget only, through its `data-mesub-*`
|
|
301
|
+
attributes (no class names), and reads your `--mesub-*` custom properties
|
|
302
|
+
without ever setting them: set `--mesub-accent`, `--mesub-bg`, `--mesub-text`,
|
|
303
|
+
`--mesub-font` or `--mesub-radius` on `:root` to brand it. Dark mode is
|
|
304
|
+
`data-mesub-theme="dark"` on the widget or an ancestor, `"auto"` follows the
|
|
305
|
+
system, and `<MesubProvider theme="dark">` sets it on the widget for you. Under
|
|
306
|
+
480px the dialog is a bottom sheet, and motion follows
|
|
307
|
+
`prefers-reduced-motion`. Leave the stylesheet out to style the attributes
|
|
308
|
+
yourself.
|
|
309
|
+
|
|
310
|
+
The dialog is a native `<dialog>`; `data-mesub-step` says where it is: `plan`,
|
|
311
|
+
`wallet`, `review`, `approve`, `confirming`, `subscribed` when subscribing, and
|
|
312
|
+
`confirm`, `wallet`, `approve`, `confirming`, `done` when managing. The list is
|
|
313
|
+
`[data-mesub-subscriptions]`, with `data-mesub-state`, one
|
|
314
|
+
`[data-mesub-subscription]` per row, `[data-mesub-status]` and
|
|
315
|
+
`[data-mesub-action]`.
|
|
316
|
+
|
|
317
|
+
## Development
|
|
318
|
+
|
|
319
|
+
```sh
|
|
320
|
+
pnpm install
|
|
321
|
+
pnpm test # in happy-dom, with Testing Library
|
|
322
|
+
pnpm typecheck
|
|
323
|
+
pnpm lint
|
|
324
|
+
pnpm build # dist/, ESM and CJS, with declaration files
|
|
325
|
+
pnpm check:exports # resolves through import and require, starts with 'use client'
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
The pre-push hook runs all of it, as CI does. [`playground/`](./playground)
|
|
329
|
+
runs the widget against a local back, with a merchant server that mounts the
|
|
330
|
+
routes.
|
|
331
|
+
|
|
332
|
+
### Releasing
|
|
333
|
+
|
|
334
|
+
A version tag on main publishes, once a reviewer approves: see
|
|
335
|
+
[RELEASING.md](./RELEASING.md) for the one-time setup, the checklist and the
|
|
336
|
+
compatibility rule with `@mesub/node`, and [CHANGELOG.md](./CHANGELOG.md) for
|
|
337
|
+
what changed.
|
|
338
|
+
|
|
339
|
+
## License
|
|
340
|
+
|
|
341
|
+
[MIT](./LICENSE)
|