forgelayer-react 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs.js +478 -0
- package/dist/index.cjs.js.map +7 -0
- package/dist/index.d.ts +121 -0
- package/dist/index.esm.js +446 -0
- package/dist/index.esm.js.map +7 -0
- package/package.json +17 -5
- package/src/ForgeLayerButton.jsx +0 -94
- package/src/ForgeLayerModal.jsx +0 -267
- package/src/index.js +0 -3
- package/src/useForgeLayerCheckout.js +0 -93
package/package.json
CHANGED
|
@@ -1,16 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "forgelayer-react",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "React components for crypto checkout powered by ForgeLayer",
|
|
5
|
-
"main": "
|
|
6
|
-
"module": "
|
|
5
|
+
"main": "dist/index.cjs.js",
|
|
6
|
+
"module": "dist/index.esm.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
7
8
|
"exports": {
|
|
8
|
-
".":
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/index.esm.js",
|
|
11
|
+
"require": "./dist/index.cjs.js",
|
|
12
|
+
"types": "./dist/index.d.ts"
|
|
13
|
+
}
|
|
9
14
|
},
|
|
10
15
|
"files": [
|
|
11
|
-
"
|
|
16
|
+
"dist/",
|
|
12
17
|
"README.md"
|
|
13
18
|
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "node build.js",
|
|
21
|
+
"prepublishOnly": "node build.js"
|
|
22
|
+
},
|
|
14
23
|
"keywords": [
|
|
15
24
|
"forgelayer", "crypto", "payments", "bitcoin", "ethereum",
|
|
16
25
|
"usdt", "react", "checkout", "modal", "components"
|
|
@@ -28,5 +37,8 @@
|
|
|
28
37
|
"peerDependencies": {
|
|
29
38
|
"react": ">=17.0.0",
|
|
30
39
|
"react-dom": ">=17.0.0"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"esbuild": "^0.28.1"
|
|
31
43
|
}
|
|
32
44
|
}
|
package/src/ForgeLayerButton.jsx
DELETED
|
@@ -1,94 +0,0 @@
|
|
|
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
|
-
}
|
package/src/ForgeLayerModal.jsx
DELETED
|
@@ -1,267 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,93 +0,0 @@
|
|
|
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
|
-
}
|