@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 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
- # Temporary Holding Version
1
+ # @mesub/react
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 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
+ [![npm](https://img.shields.io/npm/v/@mesub/react)](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)