@fragmentpay/react 0.3.1

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 Frag / FragmentPay
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 ADDED
@@ -0,0 +1,104 @@
1
+ # @fragmentpay/react
2
+
3
+ React components for the Frag hosted checkout. Zero runtime deps.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @fragmentpay/react
9
+ # or: bun add @fragmentpay/react
10
+ ```
11
+
12
+ ## Required environment variables
13
+
14
+ | Name | Where | Example |
15
+ | ----------------------------- | ----------------- | ---------------------------------------- |
16
+ | `NEXT_PUBLIC_FRAG_PUBLIC_KEY` | browser (public) | `pk_live_…` or `pk_test_…` |
17
+ | `FRAG_SECRET_KEY` | your backend only | `sk_live_…` (used by `@fragmentpay/server`) |
18
+
19
+ The React SDK never touches `sk_…`. Intent creation happens on your server
20
+ and the client uses the returned `intent.id`.
21
+
22
+ ## End-to-end example (copy-paste)
23
+
24
+ **1. Wrap your app once**
25
+
26
+ ```tsx
27
+ // app/providers.tsx
28
+ "use client";
29
+ import { FragmentPayProvider } from "@fragmentpay/react";
30
+
31
+ export function Providers({ children }: { children: React.ReactNode }) {
32
+ return (
33
+ <FragmentPayProvider publicKey={process.env.NEXT_PUBLIC_FRAG_PUBLIC_KEY!}>
34
+ {children}
35
+ </FragmentPayProvider>
36
+ );
37
+ }
38
+ ```
39
+
40
+ **2. Create a server endpoint that starts the intent**
41
+
42
+ ```ts
43
+ // app/api/frag/route.ts
44
+ import { Frag } from "@fragmentpay/server";
45
+
46
+ const frag = new Frag({ secretKey: process.env.FRAG_SECRET_KEY! });
47
+
48
+ export async function POST(req: Request) {
49
+ const { amount, email } = await req.json();
50
+ const intent = await frag.paymentIntents.create({
51
+ amount,
52
+ currency: "USD",
53
+ customerEmail: email,
54
+ settlement: { chain: "base", token: "USDC", address: process.env.MERCHANT_USDC_ADDRESS! },
55
+ });
56
+ return Response.json(intent);
57
+ }
58
+ ```
59
+
60
+ **3. Drop the button in any page — that's it**
61
+
62
+ ```tsx
63
+ // app/checkout/page.tsx
64
+ "use client";
65
+ import { FragmentPayButton } from "@fragmentpay/react";
66
+
67
+ export default function Checkout() {
68
+ return (
69
+ <FragmentPayButton
70
+ endpoint="/api/frag"
71
+ input={{ amount: 25, email: "buyer@example.com" }}
72
+ label="Pay $25 with Frag"
73
+ onSettled={(intent) => console.log("paid!", intent.id)}
74
+ onError={(intent) => console.error("payment failed", intent.status)}
75
+ />
76
+ );
77
+ }
78
+ ```
79
+
80
+ Behind the scenes: click → POST to your `/api/frag` → returns intent →
81
+ embed Frag checkout iframe → live-poll status → invoke `onSettled` when
82
+ `status === "settled"`.
83
+
84
+ ## Validation
85
+
86
+ Wrong props fail immediately with a clear message:
87
+
88
+ ```tsx
89
+ <FragmentPayProvider publicKey="oops">…
90
+ // Error: [fragmentpay] publicKey must match pk_test_… or pk_live_…
91
+ ```
92
+
93
+ ## API
94
+
95
+ - `<FragmentPayProvider publicKey baseUrl?>`
96
+ - `<FragmentPayButton endpoint input label onSettled onError>`
97
+ - `<FragmentPayCheckout intentId onSettled onError mode="iframe" | "redirect">`
98
+ - `useFragmentIntent(intentId, { intervalMs })`
99
+ - `useFragmentTokens({ chain })`
100
+ - `useCreateIntent({ endpoint, transform, headers })`
101
+
102
+ ## License
103
+
104
+ MIT
@@ -0,0 +1,99 @@
1
+ import * as React from 'react';
2
+
3
+ /**
4
+ * @fragmentpay/react
5
+ *
6
+ * Drop-in React components for Frag hosted checkout. Zero runtime deps.
7
+ */
8
+
9
+ interface FragmentPayProviderProps {
10
+ publicKey: string;
11
+ /** Override the API/host origin. Defaults to https://frag.dev. */
12
+ baseUrl?: string;
13
+ children: React.ReactNode;
14
+ }
15
+ declare function FragmentPayProvider({ publicKey, baseUrl, children }: FragmentPayProviderProps): React.JSX.Element;
16
+ interface FetchWithRetryOpts extends RequestInit {
17
+ maxRetries?: number;
18
+ baseDelayMs?: number;
19
+ maxDelayMs?: number;
20
+ /** Retry POSTs on 429/503; other 5xx are only retried for idempotent methods. */
21
+ retryOnMethods?: string[];
22
+ }
23
+ /**
24
+ * Fetch wrapper that honors Retry-After on 429/503 and does exponential backoff
25
+ * with jitter for retryable failures. Exposed for advanced integrators.
26
+ */
27
+ declare function fetchWithRetry(input: string, init?: FetchWithRetryOpts): Promise<Response>;
28
+ type IntentStatus = "created" | "requires_payment" | "quoted" | "executing" | "settled" | "failed" | "expired" | "cancelled" | "refunded";
29
+ interface PublicIntent {
30
+ id: string;
31
+ amount: string;
32
+ currency: string;
33
+ status: IntentStatus;
34
+ metadata?: Record<string, unknown>;
35
+ }
36
+ declare function useFragmentIntent(intentId: string | null, opts?: {
37
+ intervalMs?: number;
38
+ }): {
39
+ data: PublicIntent | null;
40
+ status: "idle" | "loading" | "ready" | "error";
41
+ error: string | null;
42
+ };
43
+ interface FragmentPayCheckoutProps {
44
+ intentId: string;
45
+ /** Called once the intent transitions to `settled`. */
46
+ onSettled?: (intent: PublicIntent) => void;
47
+ /** Called on `failed` | `expired` | `cancelled`. */
48
+ onError?: (intent: PublicIntent) => void;
49
+ /** Iframe height in px. Defaults to 640. */
50
+ height?: number;
51
+ className?: string;
52
+ style?: React.CSSProperties;
53
+ /** Render inline iframe (default) or open Frag checkout in a popup. */
54
+ mode?: "iframe" | "redirect";
55
+ }
56
+ declare function FragmentPayCheckout({ intentId, onSettled, onError, height, className, style, mode, }: FragmentPayCheckoutProps): React.JSX.Element;
57
+ interface FragToken {
58
+ chain: string;
59
+ address: string;
60
+ symbol: string;
61
+ decimals: number;
62
+ verified: boolean;
63
+ name?: string;
64
+ }
65
+ declare function useFragmentTokens(params?: {
66
+ chain?: string;
67
+ }): {
68
+ data: FragToken[];
69
+ loading: boolean;
70
+ error: string | null;
71
+ };
72
+ interface UseCreateIntentOptions<TInput = unknown> {
73
+ /** Absolute or relative URL on YOUR backend that creates an intent. */
74
+ endpoint: string;
75
+ /** Custom transformer if you want to shape the request body. */
76
+ transform?: (input: TInput) => unknown;
77
+ headers?: Record<string, string>;
78
+ }
79
+ declare function useCreateIntent<TInput = Record<string, unknown>>(opts: UseCreateIntentOptions<TInput>): {
80
+ mutate: (input: TInput) => Promise<PublicIntent & {
81
+ checkout_url?: string;
82
+ }>;
83
+ intent: (PublicIntent & {
84
+ checkout_url?: string;
85
+ }) | null;
86
+ loading: boolean;
87
+ error: string | null;
88
+ };
89
+ interface FragmentPayButtonProps extends UseCreateIntentOptions {
90
+ input?: Record<string, unknown>;
91
+ label?: string;
92
+ onSettled?: (intent: PublicIntent) => void;
93
+ onError?: (intent: PublicIntent) => void;
94
+ className?: string;
95
+ style?: React.CSSProperties;
96
+ }
97
+ declare function FragmentPayButton({ input, label, onSettled, onError, className, style, ...opts }: FragmentPayButtonProps): React.JSX.Element;
98
+
99
+ export { type FetchWithRetryOpts, type FragToken, FragmentPayButton, type FragmentPayButtonProps, FragmentPayCheckout, type FragmentPayCheckoutProps, FragmentPayProvider, type FragmentPayProviderProps, type IntentStatus, type PublicIntent, type UseCreateIntentOptions, fetchWithRetry, useCreateIntent, useFragmentIntent, useFragmentTokens };
@@ -0,0 +1,99 @@
1
+ import * as React from 'react';
2
+
3
+ /**
4
+ * @fragmentpay/react
5
+ *
6
+ * Drop-in React components for Frag hosted checkout. Zero runtime deps.
7
+ */
8
+
9
+ interface FragmentPayProviderProps {
10
+ publicKey: string;
11
+ /** Override the API/host origin. Defaults to https://frag.dev. */
12
+ baseUrl?: string;
13
+ children: React.ReactNode;
14
+ }
15
+ declare function FragmentPayProvider({ publicKey, baseUrl, children }: FragmentPayProviderProps): React.JSX.Element;
16
+ interface FetchWithRetryOpts extends RequestInit {
17
+ maxRetries?: number;
18
+ baseDelayMs?: number;
19
+ maxDelayMs?: number;
20
+ /** Retry POSTs on 429/503; other 5xx are only retried for idempotent methods. */
21
+ retryOnMethods?: string[];
22
+ }
23
+ /**
24
+ * Fetch wrapper that honors Retry-After on 429/503 and does exponential backoff
25
+ * with jitter for retryable failures. Exposed for advanced integrators.
26
+ */
27
+ declare function fetchWithRetry(input: string, init?: FetchWithRetryOpts): Promise<Response>;
28
+ type IntentStatus = "created" | "requires_payment" | "quoted" | "executing" | "settled" | "failed" | "expired" | "cancelled" | "refunded";
29
+ interface PublicIntent {
30
+ id: string;
31
+ amount: string;
32
+ currency: string;
33
+ status: IntentStatus;
34
+ metadata?: Record<string, unknown>;
35
+ }
36
+ declare function useFragmentIntent(intentId: string | null, opts?: {
37
+ intervalMs?: number;
38
+ }): {
39
+ data: PublicIntent | null;
40
+ status: "idle" | "loading" | "ready" | "error";
41
+ error: string | null;
42
+ };
43
+ interface FragmentPayCheckoutProps {
44
+ intentId: string;
45
+ /** Called once the intent transitions to `settled`. */
46
+ onSettled?: (intent: PublicIntent) => void;
47
+ /** Called on `failed` | `expired` | `cancelled`. */
48
+ onError?: (intent: PublicIntent) => void;
49
+ /** Iframe height in px. Defaults to 640. */
50
+ height?: number;
51
+ className?: string;
52
+ style?: React.CSSProperties;
53
+ /** Render inline iframe (default) or open Frag checkout in a popup. */
54
+ mode?: "iframe" | "redirect";
55
+ }
56
+ declare function FragmentPayCheckout({ intentId, onSettled, onError, height, className, style, mode, }: FragmentPayCheckoutProps): React.JSX.Element;
57
+ interface FragToken {
58
+ chain: string;
59
+ address: string;
60
+ symbol: string;
61
+ decimals: number;
62
+ verified: boolean;
63
+ name?: string;
64
+ }
65
+ declare function useFragmentTokens(params?: {
66
+ chain?: string;
67
+ }): {
68
+ data: FragToken[];
69
+ loading: boolean;
70
+ error: string | null;
71
+ };
72
+ interface UseCreateIntentOptions<TInput = unknown> {
73
+ /** Absolute or relative URL on YOUR backend that creates an intent. */
74
+ endpoint: string;
75
+ /** Custom transformer if you want to shape the request body. */
76
+ transform?: (input: TInput) => unknown;
77
+ headers?: Record<string, string>;
78
+ }
79
+ declare function useCreateIntent<TInput = Record<string, unknown>>(opts: UseCreateIntentOptions<TInput>): {
80
+ mutate: (input: TInput) => Promise<PublicIntent & {
81
+ checkout_url?: string;
82
+ }>;
83
+ intent: (PublicIntent & {
84
+ checkout_url?: string;
85
+ }) | null;
86
+ loading: boolean;
87
+ error: string | null;
88
+ };
89
+ interface FragmentPayButtonProps extends UseCreateIntentOptions {
90
+ input?: Record<string, unknown>;
91
+ label?: string;
92
+ onSettled?: (intent: PublicIntent) => void;
93
+ onError?: (intent: PublicIntent) => void;
94
+ className?: string;
95
+ style?: React.CSSProperties;
96
+ }
97
+ declare function FragmentPayButton({ input, label, onSettled, onError, className, style, ...opts }: FragmentPayButtonProps): React.JSX.Element;
98
+
99
+ export { type FetchWithRetryOpts, type FragToken, FragmentPayButton, type FragmentPayButtonProps, FragmentPayCheckout, type FragmentPayCheckoutProps, FragmentPayProvider, type FragmentPayProviderProps, type IntentStatus, type PublicIntent, type UseCreateIntentOptions, fetchWithRetry, useCreateIntent, useFragmentIntent, useFragmentTokens };
package/dist/index.js ADDED
@@ -0,0 +1,305 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.tsx
31
+ var index_exports = {};
32
+ __export(index_exports, {
33
+ FragmentPayButton: () => FragmentPayButton,
34
+ FragmentPayCheckout: () => FragmentPayCheckout,
35
+ FragmentPayProvider: () => FragmentPayProvider,
36
+ fetchWithRetry: () => fetchWithRetry,
37
+ useCreateIntent: () => useCreateIntent,
38
+ useFragmentIntent: () => useFragmentIntent,
39
+ useFragmentTokens: () => useFragmentTokens
40
+ });
41
+ module.exports = __toCommonJS(index_exports);
42
+ var React = __toESM(require("react"));
43
+ var import_jsx_runtime = require("react/jsx-runtime");
44
+ var FragContext = React.createContext(null);
45
+ function FragmentPayProvider({ publicKey, baseUrl, children }) {
46
+ if (typeof publicKey !== "string" || !/^pk_(test|live)_[A-Za-z0-9_-]{8,}$/.test(publicKey)) {
47
+ throw new Error("[fragmentpay] publicKey must match pk_test_\u2026 or pk_live_\u2026 (min 8 chars after prefix)");
48
+ }
49
+ if (baseUrl !== void 0) {
50
+ try {
51
+ new URL(baseUrl);
52
+ } catch {
53
+ throw new Error("[fragmentpay] baseUrl must be an absolute URL");
54
+ }
55
+ }
56
+ const value = React.useMemo(
57
+ () => ({ publicKey, baseUrl: (baseUrl ?? "https://frag.dev").replace(/\/$/, "") }),
58
+ [publicKey, baseUrl]
59
+ );
60
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FragContext.Provider, { value, children });
61
+ }
62
+ function useFrag() {
63
+ const ctx = React.useContext(FragContext);
64
+ if (!ctx) throw new Error("Wrap your tree with <FragmentPayProvider>");
65
+ return ctx;
66
+ }
67
+ var sleep = (ms, signal) => new Promise((resolve, reject) => {
68
+ if (signal?.aborted) return reject(new DOMException("Aborted", "AbortError"));
69
+ const id = setTimeout(() => {
70
+ signal?.removeEventListener("abort", onAbort);
71
+ resolve();
72
+ }, ms);
73
+ const onAbort = () => {
74
+ clearTimeout(id);
75
+ reject(new DOMException("Aborted", "AbortError"));
76
+ };
77
+ signal?.addEventListener("abort", onAbort, { once: true });
78
+ });
79
+ function parseRetryAfter(h) {
80
+ if (!h) return null;
81
+ const secs = Number(h);
82
+ if (Number.isFinite(secs) && secs >= 0) return Math.min(secs, 60) * 1e3;
83
+ const when = Date.parse(h);
84
+ if (!Number.isNaN(when)) return Math.max(0, Math.min(6e4, when - Date.now()));
85
+ return null;
86
+ }
87
+ async function fetchWithRetry(input, init = {}) {
88
+ const {
89
+ maxRetries = 4,
90
+ baseDelayMs = 500,
91
+ maxDelayMs = 8e3,
92
+ retryOnMethods = ["GET", "HEAD", "POST"],
93
+ ...rest
94
+ } = init;
95
+ const method = (rest.method ?? "GET").toUpperCase();
96
+ const canRetry = retryOnMethods.map((m) => m.toUpperCase()).includes(method);
97
+ let attempt = 0;
98
+ while (true) {
99
+ let res = null;
100
+ let netErr = null;
101
+ try {
102
+ res = await fetch(input, rest);
103
+ } catch (e) {
104
+ netErr = e;
105
+ }
106
+ const status = res?.status ?? 0;
107
+ const retriable = !!netErr || status === 429 || status === 503 || status >= 500 && status < 600;
108
+ if (!retriable || !canRetry || attempt >= maxRetries) {
109
+ if (res) return res;
110
+ throw netErr ?? new Error("network error");
111
+ }
112
+ const hinted = res ? parseRetryAfter(res.headers.get("retry-after")) : null;
113
+ const backoff = Math.min(maxDelayMs, baseDelayMs * 2 ** attempt);
114
+ const jitter = Math.random() * (backoff * 0.25);
115
+ const delay = hinted ?? backoff + jitter;
116
+ await sleep(delay, rest.signal ?? void 0);
117
+ attempt += 1;
118
+ }
119
+ }
120
+ function useFragmentIntent(intentId, opts = {}) {
121
+ const { baseUrl } = useFrag();
122
+ if (intentId !== null && (typeof intentId !== "string" || intentId.length < 3)) {
123
+ throw new Error("[fragmentpay] useFragmentIntent: intentId must be a non-empty string or null");
124
+ }
125
+ const interval = opts.intervalMs ?? 2e3;
126
+ if (typeof interval !== "number" || interval < 250 || interval > 6e4) {
127
+ throw new Error("[fragmentpay] useFragmentIntent: intervalMs must be between 250 and 60000");
128
+ }
129
+ const [state, setState] = React.useState({ data: null, status: "idle", error: null });
130
+ React.useEffect(() => {
131
+ if (!intentId) return;
132
+ let alive = true;
133
+ let timer;
134
+ async function tick() {
135
+ try {
136
+ const res = await fetchWithRetry(`${baseUrl}/api/public/v1/checkout/${intentId}`, {
137
+ maxRetries: 4
138
+ });
139
+ if (!res.ok) {
140
+ const retryAfter = parseRetryAfter(res.headers.get("retry-after")) ?? interval * 2;
141
+ throw Object.assign(new Error(`HTTP ${res.status}`), { retryAfter });
142
+ }
143
+ const data = await res.json();
144
+ if (!alive) return;
145
+ setState({ data, status: "ready", error: null });
146
+ const terminal = ["settled", "failed", "expired", "cancelled", "refunded"].includes(data.status);
147
+ if (!terminal) timer = setTimeout(tick, interval);
148
+ } catch (e) {
149
+ if (!alive) return;
150
+ const wait = e.retryAfter ?? interval * 2;
151
+ setState((s) => ({ ...s, status: "error", error: e.message }));
152
+ timer = setTimeout(tick, wait);
153
+ }
154
+ }
155
+ setState({ data: null, status: "loading", error: null });
156
+ tick();
157
+ return () => {
158
+ alive = false;
159
+ clearTimeout(timer);
160
+ };
161
+ }, [intentId, baseUrl, interval]);
162
+ return state;
163
+ }
164
+ function FragmentPayCheckout({
165
+ intentId,
166
+ onSettled,
167
+ onError,
168
+ height = 640,
169
+ className,
170
+ style,
171
+ mode = "iframe"
172
+ }) {
173
+ const { baseUrl } = useFrag();
174
+ const url = `${baseUrl}/checkout/${intentId}?embed=1`;
175
+ const intent = useFragmentIntent(intentId);
176
+ const fired = React.useRef(false);
177
+ React.useEffect(() => {
178
+ if (fired.current || !intent.data) return;
179
+ const s = intent.data.status;
180
+ if (s === "settled") {
181
+ fired.current = true;
182
+ onSettled?.(intent.data);
183
+ } else if (s === "failed" || s === "expired" || s === "cancelled") {
184
+ fired.current = true;
185
+ onError?.(intent.data);
186
+ }
187
+ }, [intent.data, onSettled, onError]);
188
+ if (mode === "redirect") {
189
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
190
+ "a",
191
+ {
192
+ href: `${baseUrl}/checkout/${intentId}`,
193
+ className,
194
+ style,
195
+ target: "_blank",
196
+ rel: "noreferrer",
197
+ children: "Pay with Frag"
198
+ }
199
+ );
200
+ }
201
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
202
+ "iframe",
203
+ {
204
+ title: "Frag checkout",
205
+ src: url,
206
+ className,
207
+ style: { width: "100%", height, border: 0, borderRadius: 8, ...style },
208
+ allow: "clipboard-write; payment"
209
+ }
210
+ );
211
+ }
212
+ function useFragmentTokens(params = {}) {
213
+ const { baseUrl } = useFrag();
214
+ const [state, setState] = React.useState({
215
+ data: [],
216
+ loading: true,
217
+ error: null
218
+ });
219
+ React.useEffect(() => {
220
+ let alive = true;
221
+ const url = params.chain ? `${baseUrl}/api/public/v1/tokens?chain=${encodeURIComponent(params.chain)}` : `${baseUrl}/api/public/v1/tokens`;
222
+ fetchWithRetry(url, { maxRetries: 3 }).then((r) => r.json()).then((j) => {
223
+ if (alive) setState({ data: j.data ?? [], loading: false, error: null });
224
+ }).catch((e) => {
225
+ if (alive) setState({ data: [], loading: false, error: e.message });
226
+ });
227
+ return () => {
228
+ alive = false;
229
+ };
230
+ }, [baseUrl, params.chain]);
231
+ return state;
232
+ }
233
+ function useCreateIntent(opts) {
234
+ if (!opts || typeof opts.endpoint !== "string" || opts.endpoint.length === 0) {
235
+ throw new Error("[fragmentpay] useCreateIntent: `endpoint` is required");
236
+ }
237
+ const [state, setState] = React.useState({ intent: null, loading: false, error: null });
238
+ const mutate = React.useCallback(async (input) => {
239
+ setState({ intent: null, loading: true, error: null });
240
+ try {
241
+ const body = opts.transform ? opts.transform(input) : input;
242
+ const res = await fetchWithRetry(opts.endpoint, {
243
+ method: "POST",
244
+ headers: { "content-type": "application/json", ...opts.headers ?? {} },
245
+ body: JSON.stringify(body),
246
+ maxRetries: 3
247
+ });
248
+ if (!res.ok) {
249
+ const retryAfter = res.headers.get("retry-after");
250
+ const suffix = res.status === 429 && retryAfter ? ` (retry after ${retryAfter}s)` : "";
251
+ throw new Error(`HTTP ${res.status}${suffix}: ${await res.text()}`);
252
+ }
253
+ const intent = await res.json();
254
+ setState({ intent, loading: false, error: null });
255
+ return intent;
256
+ } catch (e) {
257
+ setState({ intent: null, loading: false, error: e.message });
258
+ throw e;
259
+ }
260
+ }, [opts.endpoint, opts.transform, opts.headers]);
261
+ return { ...state, mutate };
262
+ }
263
+ function FragmentPayButton({
264
+ input,
265
+ label = "Pay with Frag",
266
+ onSettled,
267
+ onError,
268
+ className,
269
+ style,
270
+ ...opts
271
+ }) {
272
+ const { intent, loading, error, mutate } = useCreateIntent(opts);
273
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
274
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
275
+ "button",
276
+ {
277
+ type: "button",
278
+ className,
279
+ style,
280
+ disabled: loading,
281
+ onClick: () => mutate(input ?? {}),
282
+ children: loading ? "Loading\u2026" : label
283
+ }
284
+ ),
285
+ error && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { role: "alert", style: { color: "crimson", fontSize: 12 }, children: error }),
286
+ intent?.id && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
287
+ FragmentPayCheckout,
288
+ {
289
+ intentId: intent.id,
290
+ onSettled,
291
+ onError
292
+ }
293
+ )
294
+ ] });
295
+ }
296
+ // Annotate the CommonJS export names for ESM import in node:
297
+ 0 && (module.exports = {
298
+ FragmentPayButton,
299
+ FragmentPayCheckout,
300
+ FragmentPayProvider,
301
+ fetchWithRetry,
302
+ useCreateIntent,
303
+ useFragmentIntent,
304
+ useFragmentTokens
305
+ });
package/dist/index.mjs ADDED
@@ -0,0 +1,264 @@
1
+ // src/index.tsx
2
+ import * as React from "react";
3
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
4
+ var FragContext = React.createContext(null);
5
+ function FragmentPayProvider({ publicKey, baseUrl, children }) {
6
+ if (typeof publicKey !== "string" || !/^pk_(test|live)_[A-Za-z0-9_-]{8,}$/.test(publicKey)) {
7
+ throw new Error("[fragmentpay] publicKey must match pk_test_\u2026 or pk_live_\u2026 (min 8 chars after prefix)");
8
+ }
9
+ if (baseUrl !== void 0) {
10
+ try {
11
+ new URL(baseUrl);
12
+ } catch {
13
+ throw new Error("[fragmentpay] baseUrl must be an absolute URL");
14
+ }
15
+ }
16
+ const value = React.useMemo(
17
+ () => ({ publicKey, baseUrl: (baseUrl ?? "https://frag.dev").replace(/\/$/, "") }),
18
+ [publicKey, baseUrl]
19
+ );
20
+ return /* @__PURE__ */ jsx(FragContext.Provider, { value, children });
21
+ }
22
+ function useFrag() {
23
+ const ctx = React.useContext(FragContext);
24
+ if (!ctx) throw new Error("Wrap your tree with <FragmentPayProvider>");
25
+ return ctx;
26
+ }
27
+ var sleep = (ms, signal) => new Promise((resolve, reject) => {
28
+ if (signal?.aborted) return reject(new DOMException("Aborted", "AbortError"));
29
+ const id = setTimeout(() => {
30
+ signal?.removeEventListener("abort", onAbort);
31
+ resolve();
32
+ }, ms);
33
+ const onAbort = () => {
34
+ clearTimeout(id);
35
+ reject(new DOMException("Aborted", "AbortError"));
36
+ };
37
+ signal?.addEventListener("abort", onAbort, { once: true });
38
+ });
39
+ function parseRetryAfter(h) {
40
+ if (!h) return null;
41
+ const secs = Number(h);
42
+ if (Number.isFinite(secs) && secs >= 0) return Math.min(secs, 60) * 1e3;
43
+ const when = Date.parse(h);
44
+ if (!Number.isNaN(when)) return Math.max(0, Math.min(6e4, when - Date.now()));
45
+ return null;
46
+ }
47
+ async function fetchWithRetry(input, init = {}) {
48
+ const {
49
+ maxRetries = 4,
50
+ baseDelayMs = 500,
51
+ maxDelayMs = 8e3,
52
+ retryOnMethods = ["GET", "HEAD", "POST"],
53
+ ...rest
54
+ } = init;
55
+ const method = (rest.method ?? "GET").toUpperCase();
56
+ const canRetry = retryOnMethods.map((m) => m.toUpperCase()).includes(method);
57
+ let attempt = 0;
58
+ while (true) {
59
+ let res = null;
60
+ let netErr = null;
61
+ try {
62
+ res = await fetch(input, rest);
63
+ } catch (e) {
64
+ netErr = e;
65
+ }
66
+ const status = res?.status ?? 0;
67
+ const retriable = !!netErr || status === 429 || status === 503 || status >= 500 && status < 600;
68
+ if (!retriable || !canRetry || attempt >= maxRetries) {
69
+ if (res) return res;
70
+ throw netErr ?? new Error("network error");
71
+ }
72
+ const hinted = res ? parseRetryAfter(res.headers.get("retry-after")) : null;
73
+ const backoff = Math.min(maxDelayMs, baseDelayMs * 2 ** attempt);
74
+ const jitter = Math.random() * (backoff * 0.25);
75
+ const delay = hinted ?? backoff + jitter;
76
+ await sleep(delay, rest.signal ?? void 0);
77
+ attempt += 1;
78
+ }
79
+ }
80
+ function useFragmentIntent(intentId, opts = {}) {
81
+ const { baseUrl } = useFrag();
82
+ if (intentId !== null && (typeof intentId !== "string" || intentId.length < 3)) {
83
+ throw new Error("[fragmentpay] useFragmentIntent: intentId must be a non-empty string or null");
84
+ }
85
+ const interval = opts.intervalMs ?? 2e3;
86
+ if (typeof interval !== "number" || interval < 250 || interval > 6e4) {
87
+ throw new Error("[fragmentpay] useFragmentIntent: intervalMs must be between 250 and 60000");
88
+ }
89
+ const [state, setState] = React.useState({ data: null, status: "idle", error: null });
90
+ React.useEffect(() => {
91
+ if (!intentId) return;
92
+ let alive = true;
93
+ let timer;
94
+ async function tick() {
95
+ try {
96
+ const res = await fetchWithRetry(`${baseUrl}/api/public/v1/checkout/${intentId}`, {
97
+ maxRetries: 4
98
+ });
99
+ if (!res.ok) {
100
+ const retryAfter = parseRetryAfter(res.headers.get("retry-after")) ?? interval * 2;
101
+ throw Object.assign(new Error(`HTTP ${res.status}`), { retryAfter });
102
+ }
103
+ const data = await res.json();
104
+ if (!alive) return;
105
+ setState({ data, status: "ready", error: null });
106
+ const terminal = ["settled", "failed", "expired", "cancelled", "refunded"].includes(data.status);
107
+ if (!terminal) timer = setTimeout(tick, interval);
108
+ } catch (e) {
109
+ if (!alive) return;
110
+ const wait = e.retryAfter ?? interval * 2;
111
+ setState((s) => ({ ...s, status: "error", error: e.message }));
112
+ timer = setTimeout(tick, wait);
113
+ }
114
+ }
115
+ setState({ data: null, status: "loading", error: null });
116
+ tick();
117
+ return () => {
118
+ alive = false;
119
+ clearTimeout(timer);
120
+ };
121
+ }, [intentId, baseUrl, interval]);
122
+ return state;
123
+ }
124
+ function FragmentPayCheckout({
125
+ intentId,
126
+ onSettled,
127
+ onError,
128
+ height = 640,
129
+ className,
130
+ style,
131
+ mode = "iframe"
132
+ }) {
133
+ const { baseUrl } = useFrag();
134
+ const url = `${baseUrl}/checkout/${intentId}?embed=1`;
135
+ const intent = useFragmentIntent(intentId);
136
+ const fired = React.useRef(false);
137
+ React.useEffect(() => {
138
+ if (fired.current || !intent.data) return;
139
+ const s = intent.data.status;
140
+ if (s === "settled") {
141
+ fired.current = true;
142
+ onSettled?.(intent.data);
143
+ } else if (s === "failed" || s === "expired" || s === "cancelled") {
144
+ fired.current = true;
145
+ onError?.(intent.data);
146
+ }
147
+ }, [intent.data, onSettled, onError]);
148
+ if (mode === "redirect") {
149
+ return /* @__PURE__ */ jsx(
150
+ "a",
151
+ {
152
+ href: `${baseUrl}/checkout/${intentId}`,
153
+ className,
154
+ style,
155
+ target: "_blank",
156
+ rel: "noreferrer",
157
+ children: "Pay with Frag"
158
+ }
159
+ );
160
+ }
161
+ return /* @__PURE__ */ jsx(
162
+ "iframe",
163
+ {
164
+ title: "Frag checkout",
165
+ src: url,
166
+ className,
167
+ style: { width: "100%", height, border: 0, borderRadius: 8, ...style },
168
+ allow: "clipboard-write; payment"
169
+ }
170
+ );
171
+ }
172
+ function useFragmentTokens(params = {}) {
173
+ const { baseUrl } = useFrag();
174
+ const [state, setState] = React.useState({
175
+ data: [],
176
+ loading: true,
177
+ error: null
178
+ });
179
+ React.useEffect(() => {
180
+ let alive = true;
181
+ const url = params.chain ? `${baseUrl}/api/public/v1/tokens?chain=${encodeURIComponent(params.chain)}` : `${baseUrl}/api/public/v1/tokens`;
182
+ fetchWithRetry(url, { maxRetries: 3 }).then((r) => r.json()).then((j) => {
183
+ if (alive) setState({ data: j.data ?? [], loading: false, error: null });
184
+ }).catch((e) => {
185
+ if (alive) setState({ data: [], loading: false, error: e.message });
186
+ });
187
+ return () => {
188
+ alive = false;
189
+ };
190
+ }, [baseUrl, params.chain]);
191
+ return state;
192
+ }
193
+ function useCreateIntent(opts) {
194
+ if (!opts || typeof opts.endpoint !== "string" || opts.endpoint.length === 0) {
195
+ throw new Error("[fragmentpay] useCreateIntent: `endpoint` is required");
196
+ }
197
+ const [state, setState] = React.useState({ intent: null, loading: false, error: null });
198
+ const mutate = React.useCallback(async (input) => {
199
+ setState({ intent: null, loading: true, error: null });
200
+ try {
201
+ const body = opts.transform ? opts.transform(input) : input;
202
+ const res = await fetchWithRetry(opts.endpoint, {
203
+ method: "POST",
204
+ headers: { "content-type": "application/json", ...opts.headers ?? {} },
205
+ body: JSON.stringify(body),
206
+ maxRetries: 3
207
+ });
208
+ if (!res.ok) {
209
+ const retryAfter = res.headers.get("retry-after");
210
+ const suffix = res.status === 429 && retryAfter ? ` (retry after ${retryAfter}s)` : "";
211
+ throw new Error(`HTTP ${res.status}${suffix}: ${await res.text()}`);
212
+ }
213
+ const intent = await res.json();
214
+ setState({ intent, loading: false, error: null });
215
+ return intent;
216
+ } catch (e) {
217
+ setState({ intent: null, loading: false, error: e.message });
218
+ throw e;
219
+ }
220
+ }, [opts.endpoint, opts.transform, opts.headers]);
221
+ return { ...state, mutate };
222
+ }
223
+ function FragmentPayButton({
224
+ input,
225
+ label = "Pay with Frag",
226
+ onSettled,
227
+ onError,
228
+ className,
229
+ style,
230
+ ...opts
231
+ }) {
232
+ const { intent, loading, error, mutate } = useCreateIntent(opts);
233
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
234
+ /* @__PURE__ */ jsx(
235
+ "button",
236
+ {
237
+ type: "button",
238
+ className,
239
+ style,
240
+ disabled: loading,
241
+ onClick: () => mutate(input ?? {}),
242
+ children: loading ? "Loading\u2026" : label
243
+ }
244
+ ),
245
+ error && /* @__PURE__ */ jsx("div", { role: "alert", style: { color: "crimson", fontSize: 12 }, children: error }),
246
+ intent?.id && /* @__PURE__ */ jsx(
247
+ FragmentPayCheckout,
248
+ {
249
+ intentId: intent.id,
250
+ onSettled,
251
+ onError
252
+ }
253
+ )
254
+ ] });
255
+ }
256
+ export {
257
+ FragmentPayButton,
258
+ FragmentPayCheckout,
259
+ FragmentPayProvider,
260
+ fetchWithRetry,
261
+ useCreateIntent,
262
+ useFragmentIntent,
263
+ useFragmentTokens
264
+ };
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@fragmentpay/react",
3
+ "version": "0.3.1",
4
+ "description": "React components for Frag hosted checkout — accept any token, on any chain.",
5
+ "main": "dist/index.js",
6
+ "module": "dist/index.mjs",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.mjs",
12
+ "require": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": ["dist", "README.md", "LICENSE"],
16
+ "keywords": ["crypto", "payments", "react", "checkout", "fragmentpay", "frag"],
17
+ "peerDependencies": { "react": ">=18", "react-dom": ">=18" },
18
+ "license": "MIT",
19
+ "sideEffects": false,
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/fragmentpay/fragpay.git",
23
+ "directory": "sdk/frag-react"
24
+ },
25
+ "homepage": "https://github.com/fragmentpay/fragpay#readme",
26
+ "bugs": { "url": "https://github.com/fragmentpay/fragpay/issues" },
27
+ "publishConfig": { "access": "public", "provenance": true },
28
+ "scripts": {
29
+ "build": "tsup src/index.tsx --format esm,cjs --dts --clean",
30
+ "prepublishOnly": "npm run build"
31
+ },
32
+ "devDependencies": {
33
+ "tsup": "^8.0.0",
34
+ "typescript": "^5.4.0",
35
+ "@types/react": "^18.2.0"
36
+ }
37
+ }