forgelayer-react 1.0.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/README.md ADDED
@@ -0,0 +1,256 @@
1
+ # forgelayer-react
2
+
3
+ > React components for crypto checkout powered by [ForgeLayer](https://forgelayer.io).
4
+
5
+ Drop a `<ForgeLayerButton>` anywhere in your React app and get a full crypto payment modal — QR code, countdown timer, live status polling, and success/expired states — with zero UI dependencies.
6
+
7
+ ---
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm install forgelayer-react
13
+ ```
14
+
15
+ Requires React 17 or later. Pairs with [`forgelayer-node`](https://github.com/forgelayer-tech/forgelayer-node) on the backend.
16
+
17
+ ---
18
+
19
+ ## Quick Start
20
+
21
+ ```jsx
22
+ import { ForgeLayerButton } from 'forgelayer-react';
23
+
24
+ export default function ProductPage() {
25
+ return (
26
+ <ForgeLayerButton
27
+ amount={49.99}
28
+ currency="USD"
29
+ chain="ethereum"
30
+ token="USDT"
31
+ orderId="ORDER-123"
32
+ baseUrl="/fl"
33
+ onSuccess={(order) => console.log('Paid!', order)}
34
+ >
35
+ Pay $49.99 with USDT
36
+ </ForgeLayerButton>
37
+ );
38
+ }
39
+ ```
40
+
41
+ ---
42
+
43
+ ## How It Works
44
+
45
+ ```
46
+ <ForgeLayerButton> clicked
47
+
48
+ ├── POST /fl/create → Node.js backend (forgelayer-node)
49
+ │ generates deposit address + crypto amount
50
+
51
+ ├── modal opens → QR code + address + countdown timer
52
+
53
+ └── GET /fl/status → polls every 15 seconds
54
+ ├── pending → keep showing modal
55
+ ├── confirmed → show success state, fire onSuccess()
56
+ └── expired → show expired state, fire onExpired()
57
+ ```
58
+
59
+ All API calls go to your own backend — the React component never talks to ForgeLayer directly.
60
+
61
+ ---
62
+
63
+ ## Proxy Setup
64
+
65
+ In development, proxy `/fl` to your Node.js backend in `vite.config.js`:
66
+
67
+ ```js
68
+ export default {
69
+ server: {
70
+ proxy: {
71
+ '/fl': 'http://localhost:3000',
72
+ },
73
+ },
74
+ };
75
+ ```
76
+
77
+ In production, your reverse proxy (nginx/Caddy) handles it.
78
+
79
+ ---
80
+
81
+ ## Components
82
+
83
+ ### `<ForgeLayerButton>`
84
+
85
+ Self-contained button + modal. The simplest way to add crypto checkout.
86
+
87
+ ```jsx
88
+ <ForgeLayerButton
89
+ // Payment params
90
+ amount={49.99}
91
+ currency="USD" // default: 'USD'
92
+ chain="ethereum" // ethereum | bsc | tron | bitcoin
93
+ token="USDT" // any token supported by your backend
94
+ orderId="ORDER-123" // your order ID
95
+ paymentWindow={30} // minutes before payment expires (default: 30)
96
+ reuseAddress={false} // reuse deposit address for same orderId
97
+
98
+ // Backend
99
+ baseUrl="/fl" // path where forgelayer-node is mounted
100
+
101
+ // Button UI
102
+ label="Pay with Crypto" // overridden by children if provided
103
+ className="my-btn" // optional CSS class
104
+ style={{ width: '100%' }}
105
+
106
+ // Callbacks
107
+ onSuccess={(order) => router.push('/thank-you')}
108
+ onExpired={() => setShowExpiredMsg(true)}
109
+ onError={(err) => console.error(err)}
110
+ >
111
+ Pay $49.99 with USDT
112
+ </ForgeLayerButton>
113
+ ```
114
+
115
+ ---
116
+
117
+ ### `useForgeLayerCheckout(options)`
118
+
119
+ Hook for full control — use when you need to trigger checkout from your own button, form, or custom event.
120
+
121
+ ```jsx
122
+ import { useForgeLayerCheckout, ForgeLayerModal } from 'forgelayer-react';
123
+
124
+ function CustomCheckout() {
125
+ const { modalState, order, timeLeft, error, open, close } = useForgeLayerCheckout({
126
+ baseUrl: '/fl',
127
+ onSuccess: (order) => console.log('Confirmed:', order),
128
+ onExpired: () => console.log('Expired'),
129
+ onError: (err) => console.error(err),
130
+ });
131
+
132
+ return (
133
+ <>
134
+ <button onClick={() => open({ amount: 25, currency: 'USD', chain: 'tron', token: 'USDT', orderId: 'ORDER-1' })}>
135
+ Pay with Crypto
136
+ </button>
137
+
138
+ {modalState !== 'closed' && (
139
+ <ForgeLayerModal
140
+ modalState={modalState}
141
+ order={order}
142
+ timeLeft={timeLeft}
143
+ error={error}
144
+ onClose={close}
145
+ />
146
+ )}
147
+ </>
148
+ );
149
+ }
150
+ ```
151
+
152
+ **`open(params)`** — opens the modal and calls `/fl/create`.
153
+
154
+ | Param | Type | Description |
155
+ |---|---|---|
156
+ | `amount` | `number` | Fiat amount to charge |
157
+ | `currency` | `string` | ISO currency code (e.g. `'USD'`) |
158
+ | `chain` | `string` | `ethereum` \| `bsc` \| `tron` \| `bitcoin` |
159
+ | `token` | `string` | Token symbol (e.g. `'USDT'`, `'ETH'`, `'BTC'`) |
160
+ | `orderId` | `string` | Your order ID |
161
+ | `paymentWindow` | `number` | Minutes until expiry (default: `30`) |
162
+
163
+ **Hook return values:**
164
+
165
+ | Value | Type | Description |
166
+ |---|---|---|
167
+ | `modalState` | `string` | `'closed'` \| `'loading'` \| `'payment'` \| `'success'` \| `'expired'` \| `'error'` |
168
+ | `order` | `object \| null` | Response from `/fl/create` |
169
+ | `timeLeft` | `number \| null` | Seconds remaining in payment window |
170
+ | `error` | `string \| null` | Error message if `modalState === 'error'` |
171
+ | `open(params)` | `function` | Start a new checkout session |
172
+ | `close()` | `function` | Close the modal and reset state |
173
+
174
+ ---
175
+
176
+ ### `<ForgeLayerModal>`
177
+
178
+ The modal UI on its own. Used alongside `useForgeLayerCheckout` for custom layouts.
179
+
180
+ ```jsx
181
+ <ForgeLayerModal
182
+ modalState={modalState} // from useForgeLayerCheckout
183
+ order={order}
184
+ timeLeft={timeLeft}
185
+ error={error}
186
+ onClose={close}
187
+ />
188
+ ```
189
+
190
+ ---
191
+
192
+ ## Modal States
193
+
194
+ | State | What the user sees |
195
+ |---|---|
196
+ | `loading` | Spinner — "Generating payment address…" |
197
+ | `payment` | QR code, deposit address, amount, countdown timer |
198
+ | `success` | ✅ Payment Confirmed |
199
+ | `expired` | ⏳ Payment Expired |
200
+ | `error` | ⚠️ Error message |
201
+
202
+ ---
203
+
204
+ ## Full Example
205
+
206
+ ```jsx
207
+ import { ForgeLayerButton } from 'forgelayer-react';
208
+
209
+ const PRODUCTS = [
210
+ { id: 'PRO-001', name: 'Pro License', price: 49.99, chain: 'ethereum', token: 'USDT' },
211
+ { id: 'PLAN-002', name: 'Annual Plan', price: 119.88, chain: 'bitcoin', token: 'BTC' },
212
+ ];
213
+
214
+ export default function Shop() {
215
+ return (
216
+ <div>
217
+ {PRODUCTS.map((p) => (
218
+ <div key={p.id}>
219
+ <h2>{p.name} — ${p.price}</h2>
220
+ <ForgeLayerButton
221
+ amount={p.price}
222
+ chain={p.chain}
223
+ token={p.token}
224
+ orderId={p.id}
225
+ baseUrl="/fl"
226
+ onSuccess={() => alert('Payment received!')}
227
+ >
228
+ Pay with {p.token}
229
+ </ForgeLayerButton>
230
+ </div>
231
+ ))}
232
+ </div>
233
+ );
234
+ }
235
+ ```
236
+
237
+ ---
238
+
239
+ ## Backend
240
+
241
+ This package is the frontend half. You need [`forgelayer-node`](https://github.com/forgelayer-tech/forgelayer-node) on your Express server:
242
+
243
+ ```bash
244
+ npm install forgelayer-node
245
+ ```
246
+
247
+ ```js
248
+ const { createCheckout } = require('forgelayer-node');
249
+ app.use('/fl', createCheckout({ apiKey: process.env.FORGELAYER_API_KEY }).middleware());
250
+ ```
251
+
252
+ ---
253
+
254
+ ## License
255
+
256
+ MIT
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "forgelayer-react",
3
+ "version": "1.0.0",
4
+ "description": "React components for crypto checkout powered by ForgeLayer",
5
+ "main": "src/index.js",
6
+ "module": "src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js"
9
+ },
10
+ "files": [
11
+ "src/",
12
+ "README.md"
13
+ ],
14
+ "keywords": [
15
+ "forgelayer", "crypto", "payments", "bitcoin", "ethereum",
16
+ "usdt", "react", "checkout", "modal", "components"
17
+ ],
18
+ "author": "ForgeLayer <support@forgelayer.io>",
19
+ "license": "MIT",
20
+ "homepage": "https://github.com/forgelayer-tech/forgelayer-react#readme",
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/forgelayer-tech/forgelayer-react.git"
24
+ },
25
+ "bugs": {
26
+ "url": "https://github.com/forgelayer-tech/forgelayer-react/issues"
27
+ },
28
+ "peerDependencies": {
29
+ "react": ">=17.0.0",
30
+ "react-dom": ">=17.0.0"
31
+ }
32
+ }
@@ -0,0 +1,94 @@
1
+ import React from 'react';
2
+ import { useForgeLayerCheckout } from './useForgeLayerCheckout.js';
3
+ import { ForgeLayerModal } from './ForgeLayerModal.jsx';
4
+
5
+ /**
6
+ * Drop-in React checkout button.
7
+ *
8
+ * Usage:
9
+ * <ForgeLayerButton
10
+ * amount={49.99}
11
+ * currency="USD"
12
+ * chain="ethereum"
13
+ * token="USDT"
14
+ * orderId="ORDER-123"
15
+ * onSuccess={(order) => router.push('/thank-you')}
16
+ * >
17
+ * Pay $49.99
18
+ * </ForgeLayerButton>
19
+ */
20
+ export function ForgeLayerButton({
21
+ // Payment params
22
+ amount,
23
+ currency = 'USD',
24
+ chain = 'ethereum',
25
+ token = 'USDT',
26
+ orderId,
27
+ paymentWindow,
28
+ reuseAddress,
29
+ // Backend base path (proxied or absolute)
30
+ baseUrl = '/fl',
31
+ // Button UI
32
+ label,
33
+ children,
34
+ className,
35
+ style,
36
+ disabled,
37
+ // Callbacks
38
+ onSuccess,
39
+ onExpired,
40
+ onError,
41
+ }) {
42
+ const { modalState, order, timeLeft, error, open, close } = useForgeLayerCheckout({
43
+ baseUrl,
44
+ onSuccess,
45
+ onExpired,
46
+ onError,
47
+ });
48
+
49
+ const isOpen = modalState !== 'closed';
50
+
51
+ const handleClick = () => {
52
+ open({ amount, currency, chain, token, orderId, paymentWindow, reuseAddress });
53
+ };
54
+
55
+ return (
56
+ <>
57
+ <button
58
+ onClick={handleClick}
59
+ disabled={disabled || isOpen}
60
+ className={className}
61
+ style={{
62
+ display: 'inline-flex',
63
+ alignItems: 'center',
64
+ gap: 8,
65
+ padding: '12px 24px',
66
+ background: '#f7931a',
67
+ color: '#fff',
68
+ border: 'none',
69
+ borderRadius: 8,
70
+ fontSize: 15,
71
+ fontWeight: 600,
72
+ cursor: (disabled || isOpen) ? 'not-allowed' : 'pointer',
73
+ opacity: (disabled || isOpen) ? 0.65 : 1,
74
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
75
+ lineHeight: 1.2,
76
+ transition: 'opacity .15s',
77
+ ...style,
78
+ }}
79
+ >
80
+ {label ?? children ?? 'Pay with Crypto'}
81
+ </button>
82
+
83
+ {isOpen && (
84
+ <ForgeLayerModal
85
+ modalState={modalState}
86
+ order={order}
87
+ timeLeft={timeLeft}
88
+ error={error}
89
+ onClose={close}
90
+ />
91
+ )}
92
+ </>
93
+ );
94
+ }
@@ -0,0 +1,267 @@
1
+ import React, { useEffect, useState } from 'react';
2
+
3
+ // Injected once — handles animations, hover, and pseudo-elements
4
+ // that can't be done with inline styles.
5
+ const MODAL_CSS = `
6
+ @keyframes fl-r-fade{from{opacity:0}to{opacity:1}}
7
+ @keyframes fl-r-up{from{transform:translateY(16px);opacity:0}to{transform:translateY(0);opacity:1}}
8
+ @keyframes fl-r-spin{to{transform:rotate(360deg)}}
9
+ @keyframes fl-r-pulse{0%,100%{opacity:1}50%{opacity:.35}}
10
+ .fl-r-backdrop{position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:99999;
11
+ display:flex;align-items:center;justify-content:center;padding:16px;
12
+ animation:fl-r-fade .18s ease}
13
+ .fl-r-modal{background:#fff;border-radius:16px;width:100%;max-width:520px;
14
+ box-shadow:0 24px 64px rgba(0,0,0,.28);overflow:hidden;
15
+ animation:fl-r-up .22s ease;
16
+ font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
17
+ font-size:14px;color:#111}
18
+ .fl-r-spinner{width:38px;height:38px;border:3px solid #e5e7eb;
19
+ border-top-color:#f7931a;border-radius:50%;
20
+ animation:fl-r-spin .7s linear infinite;margin:0 auto 14px}
21
+ .fl-r-dot-pending{background:#f59e0b;animation:fl-r-pulse 1.6s ease-in-out infinite}
22
+ .fl-r-dot-confirmed{background:#10b981}
23
+ .fl-r-dot-expired{background:#ef4444}
24
+ .fl-r-xbtn:hover{background:#f3f4f6 !important;color:#111 !important}
25
+ .fl-r-cpbtn:hover{background:#f3f4f6 !important}
26
+ .fl-r-cpbtn-copied{border-color:#10b981 !important;color:#059669 !important}
27
+ @media(max-width:460px){.fl-r-grid{flex-direction:column !important;align-items:center !important}}
28
+ `;
29
+
30
+ let _cssInjected = false;
31
+ function ensureCSS() {
32
+ if (_cssInjected || typeof document === 'undefined') return;
33
+ _cssInjected = true;
34
+ const el = document.createElement('style');
35
+ el.textContent = MODAL_CSS;
36
+ document.head.appendChild(el);
37
+ }
38
+
39
+ function fmtTime(secs) {
40
+ const m = Math.floor(secs / 60);
41
+ const s = secs % 60;
42
+ return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
43
+ }
44
+
45
+ export function ForgeLayerModal({ modalState, order, timeLeft, error, onClose }) {
46
+ const [copied, setCopied] = useState(false);
47
+
48
+ useEffect(() => { ensureCSS(); }, []);
49
+
50
+ // Escape key
51
+ useEffect(() => {
52
+ const handler = (e) => { if (e.key === 'Escape') onClose(); };
53
+ document.addEventListener('keydown', handler);
54
+ return () => document.removeEventListener('keydown', handler);
55
+ }, [onClose]);
56
+
57
+ // Lock body scroll
58
+ useEffect(() => {
59
+ const prev = document.body.style.overflow;
60
+ document.body.style.overflow = 'hidden';
61
+ return () => { document.body.style.overflow = prev; };
62
+ }, []);
63
+
64
+ const copyAddress = () => {
65
+ if (!order?.address) return;
66
+ navigator.clipboard?.writeText(order.address).catch(() => {});
67
+ setCopied(true);
68
+ setTimeout(() => setCopied(false), 2000);
69
+ };
70
+
71
+ const urgent = timeLeft !== null && timeLeft <= 120;
72
+
73
+ return (
74
+ <div
75
+ className="fl-r-backdrop"
76
+ role="dialog"
77
+ aria-modal="true"
78
+ aria-label="Crypto payment"
79
+ onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
80
+ >
81
+ <div className="fl-r-modal">
82
+
83
+ {/* ── Header ── */}
84
+ <div style={{
85
+ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
86
+ padding: '16px 18px', borderBottom: '1px solid #e5e7eb', background: '#fafafa',
87
+ }}>
88
+ <div style={{ display: 'flex', alignItems: 'center', gap: 9, fontSize: 15, fontWeight: 700 }}>
89
+ <div style={{
90
+ width: 26, height: 26, background: '#f7931a', borderRadius: 6,
91
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
92
+ color: '#fff', fontSize: 10, fontWeight: 800, flexShrink: 0,
93
+ }}>FL</div>
94
+ Pay with Crypto
95
+ </div>
96
+ <button
97
+ className="fl-r-xbtn"
98
+ onClick={onClose}
99
+ aria-label="Close"
100
+ style={{
101
+ background: 'none', border: 'none', fontSize: 22, lineHeight: 1,
102
+ cursor: 'pointer', color: '#6b7280', padding: '4px 8px',
103
+ borderRadius: 5, transition: 'background .12s',
104
+ }}
105
+ >×</button>
106
+ </div>
107
+
108
+ {/* ── Body ── */}
109
+ <div style={{ padding: '20px 18px' }}>
110
+
111
+ {/* Loading */}
112
+ {modalState === 'loading' && (
113
+ <div style={{ textAlign: 'center', padding: '28px 0' }}>
114
+ <div className="fl-r-spinner" />
115
+ <p style={{ color: '#6b7280', fontSize: 13, margin: 0 }}>
116
+ Generating payment address…
117
+ </p>
118
+ </div>
119
+ )}
120
+
121
+ {/* Error */}
122
+ {modalState === 'error' && (
123
+ <div style={{ textAlign: 'center', padding: '28px 0' }}>
124
+ <div style={{ fontSize: 48, marginBottom: 12 }}>⚠️</div>
125
+ <div style={{ fontSize: 15, fontWeight: 700, marginBottom: 8 }}>Something went wrong</div>
126
+ <div style={{ fontSize: 13, color: '#6b7280' }}>{error}</div>
127
+ </div>
128
+ )}
129
+
130
+ {/* Payment */}
131
+ {modalState === 'payment' && order && (
132
+ <>
133
+ {/* Status bar */}
134
+ <div style={{
135
+ display: 'flex', alignItems: 'center', gap: 9,
136
+ padding: '9px 13px', borderRadius: 8,
137
+ background: '#f9fafb', border: '1px solid #e5e7eb',
138
+ marginBottom: 14, fontSize: 13,
139
+ }}>
140
+ <span
141
+ className="fl-r-dot-pending"
142
+ style={{ width: 8, height: 8, borderRadius: '50%', flexShrink: 0 }}
143
+ />
144
+ <span>Awaiting payment…</span>
145
+ <span style={{
146
+ marginLeft: 'auto', fontWeight: 600, fontSize: 13,
147
+ color: urgent ? '#ef4444' : '#374151',
148
+ fontVariantNumeric: 'tabular-nums',
149
+ }}>
150
+ {timeLeft !== null ? fmtTime(timeLeft) : '--:--'}
151
+ </span>
152
+ </div>
153
+
154
+ {/* Network warning */}
155
+ <div style={{
156
+ background: '#fffbeb', border: '1px solid #fde68a', borderRadius: 8,
157
+ padding: '9px 13px', fontSize: 12, color: '#92400e',
158
+ marginBottom: 14, lineHeight: 1.5,
159
+ }}>
160
+ <strong>⚠ Important:</strong> Send only <strong>{order.token}</strong> on the{' '}
161
+ <strong>{order.chainName}</strong> network only. Wrong network = permanent loss.
162
+ </div>
163
+
164
+ {/* QR + info grid */}
165
+ <div className="fl-r-grid" style={{ display: 'flex', gap: 18, marginBottom: 14 }}>
166
+ {/* QR side */}
167
+ <div style={{ flexShrink: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6 }}>
168
+ <img
169
+ src={order.qrUrl}
170
+ alt={`Send to ${order.address}`}
171
+ width={148}
172
+ height={148}
173
+ style={{ border: '1px solid #e5e7eb', borderRadius: 10, display: 'block', background: '#f9fafb' }}
174
+ />
175
+ <p style={{ fontSize: 11, color: '#9ca3af', margin: 0 }}>Scan with wallet</p>
176
+ </div>
177
+
178
+ {/* Info side */}
179
+ <div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 13 }}>
180
+ {/* Amount */}
181
+ <div>
182
+ <label style={{ display: 'block', fontSize: 10, fontWeight: 700, color: '#6b7280', textTransform: 'uppercase', letterSpacing: '.06em', marginBottom: 4 }}>
183
+ Amount to Send
184
+ </label>
185
+ <div style={{ fontSize: 22, fontWeight: 700, lineHeight: 1.2 }}>
186
+ {order.cryptoAmount
187
+ ? `${parseFloat(order.cryptoAmount).toFixed(8).replace(/\.?0+$/, '')} ${order.token}`
188
+ : `${order.currency} ${parseFloat(order.amount).toFixed(2)}`}
189
+ </div>
190
+ {order.cryptoAmount && (
191
+ <div style={{ fontSize: 12, color: '#6b7280', marginTop: 2 }}>
192
+ ≈ {order.currency} {parseFloat(order.amount).toFixed(2)}
193
+ </div>
194
+ )}
195
+ </div>
196
+
197
+ {/* Address */}
198
+ <div>
199
+ <label style={{ display: 'block', fontSize: 10, fontWeight: 700, color: '#6b7280', textTransform: 'uppercase', letterSpacing: '.06em', marginBottom: 4 }}>
200
+ Deposit Address
201
+ </label>
202
+ <div style={{ display: 'flex', alignItems: 'flex-start', gap: 7 }}>
203
+ <span style={{ fontFamily: 'monospace', fontSize: 12, color: '#374151', wordBreak: 'break-all', flex: 1, lineHeight: 1.5 }}>
204
+ {order.address}
205
+ </span>
206
+ <button
207
+ className={`fl-r-cpbtn${copied ? ' fl-r-cpbtn-copied' : ''}`}
208
+ onClick={copyAddress}
209
+ style={{
210
+ flexShrink: 0, background: '#fff', border: '1px solid #d1d5db',
211
+ borderRadius: 6, padding: '5px 11px', fontSize: 12,
212
+ cursor: 'pointer', color: '#374151',
213
+ transition: 'background .12s', whiteSpace: 'nowrap',
214
+ }}
215
+ >
216
+ {copied ? 'Copied!' : 'Copy'}
217
+ </button>
218
+ </div>
219
+ </div>
220
+
221
+ {/* Network badge */}
222
+ <div>
223
+ <label style={{ display: 'block', fontSize: 10, fontWeight: 700, color: '#6b7280', textTransform: 'uppercase', letterSpacing: '.06em', marginBottom: 4 }}>
224
+ Network
225
+ </label>
226
+ <span style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '3px 10px', background: '#f3f4f6', borderRadius: 100, fontSize: 12, fontWeight: 500, color: '#374151' }}>
227
+ <span style={{ width: 7, height: 7, borderRadius: '50%', background: '#10b981', flexShrink: 0 }} />
228
+ {order.chainName} · {order.token}
229
+ </span>
230
+ </div>
231
+ </div>
232
+ </div>
233
+ </>
234
+ )}
235
+
236
+ {/* Success */}
237
+ {modalState === 'success' && (
238
+ <div style={{ textAlign: 'center', padding: '30px 16px' }}>
239
+ <div style={{ fontSize: 52, marginBottom: 14 }}>✅</div>
240
+ <div style={{ fontSize: 19, fontWeight: 700, color: '#111', marginBottom: 7 }}>Payment Confirmed!</div>
241
+ <div style={{ fontSize: 13, color: '#6b7280' }}>Your payment has been received.</div>
242
+ </div>
243
+ )}
244
+
245
+ {/* Expired */}
246
+ {modalState === 'expired' && (
247
+ <div style={{ textAlign: 'center', padding: '30px 16px' }}>
248
+ <div style={{ fontSize: 52, marginBottom: 14 }}>⏳</div>
249
+ <div style={{ fontSize: 19, fontWeight: 700, color: '#111', marginBottom: 7 }}>Payment Expired</div>
250
+ <div style={{ fontSize: 13, color: '#6b7280' }}>
251
+ The payment window has closed. Please start a new payment.
252
+ </div>
253
+ </div>
254
+ )}
255
+ </div>
256
+
257
+ {/* ── Footer ── */}
258
+ <div style={{ padding: '10px 18px 14px', textAlign: 'center', fontSize: 11, color: '#9ca3af', borderTop: '1px solid #f3f4f6' }}>
259
+ Secured by{' '}
260
+ <a href="https://forgelayer.io" target="_blank" rel="noopener noreferrer" style={{ color: '#f7931a', textDecoration: 'none' }}>
261
+ ForgeLayer
262
+ </a>
263
+ </div>
264
+ </div>
265
+ </div>
266
+ );
267
+ }
package/src/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { ForgeLayerButton } from './ForgeLayerButton.jsx';
2
+ export { ForgeLayerModal } from './ForgeLayerModal.jsx';
3
+ export { useForgeLayerCheckout } from './useForgeLayerCheckout.js';
@@ -0,0 +1,93 @@
1
+ 'use strict';
2
+ import { useState, useRef, useCallback, useEffect } from 'react';
3
+
4
+ /**
5
+ * Core hook — handles all state and API calls for the checkout flow.
6
+ *
7
+ * modalState values:
8
+ * 'closed' | 'loading' | 'payment' | 'success' | 'expired' | 'error'
9
+ */
10
+ export function useForgeLayerCheckout({ baseUrl = '/fl', onSuccess, onExpired, onError } = {}) {
11
+ const [modalState, setModalState] = useState('closed');
12
+ const [order, setOrder] = useState(null);
13
+ const [timeLeft, setTimeLeft] = useState(null);
14
+ const [error, setError] = useState(null);
15
+
16
+ const pollRef = useRef(null);
17
+ const cdRef = useRef(null);
18
+
19
+ const stopTimers = useCallback(() => {
20
+ if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
21
+ if (cdRef.current) { clearInterval(cdRef.current); cdRef.current = null; }
22
+ }, []);
23
+
24
+ // Cleanup on unmount
25
+ useEffect(() => stopTimers, [stopTimers]);
26
+
27
+ const close = useCallback(() => {
28
+ stopTimers();
29
+ setModalState('closed');
30
+ setOrder(null);
31
+ setTimeLeft(null);
32
+ setError(null);
33
+ }, [stopTimers]);
34
+
35
+ const startCountdown = useCallback((expiresAt) => {
36
+ if (cdRef.current) clearInterval(cdRef.current);
37
+ const tick = () => {
38
+ const rem = expiresAt - Math.floor(Date.now() / 1000);
39
+ setTimeLeft(rem <= 0 ? 0 : rem);
40
+ if (rem <= 0) clearInterval(cdRef.current);
41
+ };
42
+ tick();
43
+ cdRef.current = setInterval(tick, 1000);
44
+ }, []);
45
+
46
+ const startPolling = useCallback((orderData) => {
47
+ if (pollRef.current) clearInterval(pollRef.current);
48
+ pollRef.current = setInterval(async () => {
49
+ try {
50
+ const res = await fetch(`${baseUrl}/status?session=${encodeURIComponent(orderData.sessionKey)}`);
51
+ const data = await res.json();
52
+ if (!data.ok) return;
53
+ if (data.status === 'confirmed') {
54
+ stopTimers();
55
+ setModalState('success');
56
+ onSuccess?.(orderData);
57
+ } else if (data.status === 'expired') {
58
+ stopTimers();
59
+ setModalState('expired');
60
+ onExpired?.();
61
+ }
62
+ } catch (_) {}
63
+ }, 15_000);
64
+ }, [baseUrl, stopTimers, onSuccess, onExpired]);
65
+
66
+ const open = useCallback(async (params) => {
67
+ stopTimers();
68
+ setModalState('loading');
69
+ setOrder(null);
70
+ setTimeLeft(null);
71
+ setError(null);
72
+
73
+ try {
74
+ const res = await fetch(`${baseUrl}/create`, {
75
+ method: 'POST',
76
+ headers: { 'Content-Type': 'application/json' },
77
+ body: JSON.stringify(params),
78
+ });
79
+ const data = await res.json();
80
+ if (!data.ok) throw new Error(data.error || 'Failed to generate payment address.');
81
+ setOrder(data);
82
+ setModalState('payment');
83
+ startCountdown(data.expiresAt);
84
+ startPolling(data);
85
+ } catch (e) {
86
+ setError(e.message);
87
+ setModalState('error');
88
+ onError?.(e);
89
+ }
90
+ }, [baseUrl, stopTimers, startCountdown, startPolling, onError]);
91
+
92
+ return { modalState, order, timeLeft, error, open, close };
93
+ }