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 +256 -0
- package/package.json +32 -0
- package/src/ForgeLayerButton.jsx +94 -0
- package/src/ForgeLayerModal.jsx +267 -0
- package/src/index.js +3 -0
- package/src/useForgeLayerCheckout.js +93 -0
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,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
|
+
}
|