torque-checkout 2.0.0 → 2.0.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/README.md +528 -195
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,20 +5,37 @@
|
|
|
5
5
|
The easiest way to integrate Torque checkout into your Next.js eCommerce application. Accept crypto payments with just a few lines of code.
|
|
6
6
|
|
|
7
7
|
[](https://www.npmjs.com/package/torque-checkout)
|
|
8
|
+
[](https://www.npmjs.com/package/torque-checkout)
|
|
8
9
|
[](https://opensource.org/licenses/MIT)
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
19
|
-
-
|
|
20
|
-
|
|
21
|
-
|
|
10
|
+
[](https://www.typescriptlang.org/)
|
|
11
|
+
|
|
12
|
+
## Table of Contents
|
|
13
|
+
|
|
14
|
+
- [Features](#features)
|
|
15
|
+
- [Installation](#installation)
|
|
16
|
+
- [Quick Start](#quick-start)
|
|
17
|
+
- [Documentation](#documentation)
|
|
18
|
+
- [Examples](#examples)
|
|
19
|
+
- [TypeScript](#typescript)
|
|
20
|
+
- [Error Handling](#error-handling)
|
|
21
|
+
- [Security](#security)
|
|
22
|
+
- [Troubleshooting](#troubleshooting)
|
|
23
|
+
- [Migration Guide](#migration-guide)
|
|
24
|
+
- [Support](#support)
|
|
25
|
+
|
|
26
|
+
## Features
|
|
27
|
+
|
|
28
|
+
- **Lightweight & Fast** - Minimal bundle size (~40KB), optimized for performance
|
|
29
|
+
- **Zero Configuration** - Works out of the box with environment variables
|
|
30
|
+
- **TypeScript First** - Full TypeScript support with comprehensive types and IntelliSense
|
|
31
|
+
- **React Hooks** - Built-in React hooks for seamless client-side integration
|
|
32
|
+
- **Next.js Optimized** - Server-side utilities for App Router and Pages Router
|
|
33
|
+
- **Production Ready** - Comprehensive error handling, validation, and retry logic
|
|
34
|
+
- **Multi-Product Support** - Handle complex carts with multiple items and variants
|
|
35
|
+
- **Subscription Support** - Built-in subscription and recurring payment management
|
|
36
|
+
- **Universal** - Works in browser, Node.js, and edge environments
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
22
39
|
|
|
23
40
|
```bash
|
|
24
41
|
npm install torque-checkout
|
|
@@ -28,34 +45,46 @@ yarn add torque-checkout
|
|
|
28
45
|
pnpm add torque-checkout
|
|
29
46
|
```
|
|
30
47
|
|
|
31
|
-
|
|
48
|
+
### Requirements
|
|
49
|
+
|
|
50
|
+
- **Node.js**: >= 16.0.0
|
|
51
|
+
- **Next.js**: >= 13.0.0 (for Next.js utilities)
|
|
52
|
+
- **React**: >= 16.8.0 (for React hooks, optional)
|
|
32
53
|
|
|
33
|
-
|
|
54
|
+
## Quick Start
|
|
34
55
|
|
|
35
|
-
|
|
56
|
+
### Step 1: Get Your API Credentials
|
|
36
57
|
|
|
37
|
-
1. **
|
|
58
|
+
1. **Visit [Torque Business Dashboard](https://app.torque.fi/business/settings)**
|
|
38
59
|
2. **Connect your wallet** (MetaMask, WalletConnect, etc.)
|
|
39
|
-
3. **
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
-
|
|
60
|
+
3. **Complete your business profile**:
|
|
61
|
+
- Business name, email, website
|
|
62
|
+
- **Payment wallet address** (where payments will be sent)
|
|
63
|
+
4. **Save your profile** - API credentials are generated automatically
|
|
64
|
+
5. **Copy your credentials** from the API Integration section:
|
|
65
|
+
- `Business ID`
|
|
66
|
+
- `API Key`
|
|
45
67
|
|
|
46
|
-
|
|
68
|
+
> **Tip**: Your payment wallet address is set once in business settings. All successful payments automatically go to this wallet - no code needed!
|
|
47
69
|
|
|
48
|
-
|
|
70
|
+
### Step 2: Configure Environment Variables
|
|
71
|
+
|
|
72
|
+
Create a `.env.local` file in your Next.js project root:
|
|
49
73
|
|
|
50
74
|
```env
|
|
75
|
+
# Required
|
|
51
76
|
TORQUE_BUSINESS_ID=your_business_id_here
|
|
52
77
|
TORQUE_API_KEY=your_api_key_here
|
|
53
|
-
|
|
78
|
+
|
|
79
|
+
# Optional (defaults to production)
|
|
80
|
+
TORQUE_BASE_URL=https://app.torque.fi
|
|
54
81
|
```
|
|
55
82
|
|
|
56
|
-
|
|
83
|
+
> **Security**: Never commit `.env.local` to version control. Add it to `.gitignore`.
|
|
57
84
|
|
|
58
|
-
|
|
85
|
+
### Step 3: Choose Your Integration Method
|
|
86
|
+
|
|
87
|
+
#### Option A: React Hook (Client-Side) - Recommended for Most Cases
|
|
59
88
|
|
|
60
89
|
```tsx
|
|
61
90
|
'use client'
|
|
@@ -64,82 +93,78 @@ import { useTorqueCheckout } from 'torque-checkout/react'
|
|
|
64
93
|
|
|
65
94
|
export default function CheckoutButton() {
|
|
66
95
|
const { generateProductCheckout, isLoading } = useTorqueCheckout({
|
|
67
|
-
autoRedirect: true
|
|
68
|
-
onSuccess: (url) => console.log('Checkout URL:', url),
|
|
69
|
-
onError: (error) => console.error('Error:', error)
|
|
96
|
+
autoRedirect: true // Automatically redirects to checkout
|
|
70
97
|
})
|
|
71
98
|
|
|
72
|
-
const handleCheckout = async () => {
|
|
73
|
-
await generateProductCheckout('prod_123', 1, {
|
|
74
|
-
email: 'customer@example.com',
|
|
75
|
-
firstName: 'John',
|
|
76
|
-
lastName: 'Doe'
|
|
77
|
-
})
|
|
78
|
-
}
|
|
79
|
-
|
|
80
99
|
return (
|
|
81
|
-
<button
|
|
82
|
-
{
|
|
100
|
+
<button
|
|
101
|
+
onClick={() => generateProductCheckout('prod_123', 1, {
|
|
102
|
+
email: 'customer@example.com'
|
|
103
|
+
})}
|
|
104
|
+
disabled={isLoading}
|
|
105
|
+
>
|
|
106
|
+
{isLoading ? 'Loading...' : 'Buy Now'}
|
|
83
107
|
</button>
|
|
84
108
|
)
|
|
85
109
|
}
|
|
86
110
|
```
|
|
87
111
|
|
|
88
|
-
#### Server-Side
|
|
112
|
+
#### Option B: Server-Side API Route (Most Secure)
|
|
89
113
|
|
|
90
114
|
```ts
|
|
91
115
|
// app/api/checkout/route.ts
|
|
92
116
|
import { handleCheckoutRequest } from 'torque-checkout/nextjs'
|
|
93
117
|
|
|
94
|
-
export const POST = handleCheckoutRequest(
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
118
|
+
export const POST = handleCheckoutRequest()
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Then call from your frontend:
|
|
122
|
+
|
|
123
|
+
```tsx
|
|
124
|
+
const response = await fetch('/api/checkout', {
|
|
125
|
+
method: 'POST',
|
|
126
|
+
headers: { 'Content-Type': 'application/json' },
|
|
127
|
+
body: JSON.stringify({
|
|
128
|
+
items: [{ productId: 'prod_123', quantity: 1 }],
|
|
129
|
+
customer: { email: 'customer@example.com' }
|
|
130
|
+
})
|
|
99
131
|
})
|
|
132
|
+
|
|
133
|
+
const { checkoutUrl } = await response.json()
|
|
134
|
+
window.location.href = checkoutUrl
|
|
100
135
|
```
|
|
101
136
|
|
|
102
|
-
#### Direct SDK Usage
|
|
137
|
+
#### Option C: Direct SDK Usage
|
|
103
138
|
|
|
104
139
|
```ts
|
|
105
|
-
import {
|
|
140
|
+
import { createTorqueCheckoutFromEnv } from 'torque-checkout'
|
|
106
141
|
|
|
107
|
-
const torque =
|
|
108
|
-
businessId: process.env.TORQUE_BUSINESS_ID!,
|
|
109
|
-
apiKey: process.env.TORQUE_API_KEY!
|
|
110
|
-
})
|
|
142
|
+
const torque = createTorqueCheckoutFromEnv()
|
|
111
143
|
|
|
112
|
-
// Generate checkout URL
|
|
113
144
|
const checkoutUrl = await torque.generateCartCheckoutUrl({
|
|
114
|
-
items: [
|
|
115
|
-
|
|
116
|
-
{ productId: 'prod_456', quantity: 1 }
|
|
117
|
-
],
|
|
118
|
-
customer: {
|
|
119
|
-
email: 'customer@example.com',
|
|
120
|
-
firstName: 'John',
|
|
121
|
-
lastName: 'Doe'
|
|
122
|
-
}
|
|
145
|
+
items: [{ productId: 'prod_123', quantity: 1 }],
|
|
146
|
+
customer: { email: 'customer@example.com' }
|
|
123
147
|
})
|
|
124
148
|
|
|
125
|
-
// Redirect customer
|
|
126
149
|
window.location.href = checkoutUrl
|
|
127
150
|
```
|
|
128
151
|
|
|
129
|
-
##
|
|
152
|
+
## Documentation
|
|
130
153
|
|
|
131
154
|
### Core SDK
|
|
132
155
|
|
|
133
|
-
####
|
|
134
|
-
|
|
135
|
-
The main SDK class for generating checkout URLs and managing orders.
|
|
156
|
+
#### Initialization
|
|
136
157
|
|
|
137
158
|
```ts
|
|
138
|
-
import { createTorqueCheckout } from 'torque-checkout'
|
|
159
|
+
import { createTorqueCheckout, createTorqueCheckoutFromEnv } from 'torque-checkout'
|
|
139
160
|
|
|
161
|
+
// Method 1: From environment variables (recommended)
|
|
162
|
+
const torque = createTorqueCheckoutFromEnv()
|
|
163
|
+
|
|
164
|
+
// Method 2: Explicit configuration
|
|
140
165
|
const torque = createTorqueCheckout({
|
|
141
|
-
businessId:
|
|
142
|
-
apiKey:
|
|
166
|
+
businessId: process.env.TORQUE_BUSINESS_ID!,
|
|
167
|
+
apiKey: process.env.TORQUE_API_KEY!,
|
|
143
168
|
baseUrl: 'https://app.torque.fi', // Optional
|
|
144
169
|
timeout: 30000 // Optional, default: 30000ms
|
|
145
170
|
})
|
|
@@ -152,16 +177,23 @@ const torque = createTorqueCheckout({
|
|
|
152
177
|
Generate a checkout URL for a multi-product cart.
|
|
153
178
|
|
|
154
179
|
```ts
|
|
155
|
-
const
|
|
180
|
+
const checkoutUrl = await torque.generateCartCheckoutUrl({
|
|
156
181
|
items: [
|
|
157
|
-
{
|
|
158
|
-
|
|
182
|
+
{
|
|
183
|
+
productId: 'prod_123',
|
|
184
|
+
quantity: 2,
|
|
185
|
+
price: 29.99, // Optional: override product price
|
|
186
|
+
variant: 'large', // Optional: product variant
|
|
187
|
+
metadata: { customField: 'value' } // Optional: custom data
|
|
188
|
+
},
|
|
189
|
+
{ productId: 'prod_456', quantity: 1 }
|
|
159
190
|
],
|
|
160
191
|
customer: {
|
|
161
|
-
email: 'customer@example.com',
|
|
162
|
-
firstName: 'John',
|
|
163
|
-
lastName: 'Doe',
|
|
164
|
-
|
|
192
|
+
email: 'customer@example.com', // Required
|
|
193
|
+
firstName: 'John', // Optional
|
|
194
|
+
lastName: 'Doe', // Optional
|
|
195
|
+
phone: '+1234567890', // Optional
|
|
196
|
+
shippingAddress: { // Optional: pre-fill shipping
|
|
165
197
|
street: '123 Main St',
|
|
166
198
|
city: 'New York',
|
|
167
199
|
state: 'NY',
|
|
@@ -170,66 +202,75 @@ const url = await torque.generateCartCheckoutUrl({
|
|
|
170
202
|
}
|
|
171
203
|
},
|
|
172
204
|
options: {
|
|
173
|
-
metadata: { orderId: 'order_123', source: 'website' },
|
|
174
|
-
expiresIn: 24 * 60 * 60 * 1000, // 24 hours
|
|
175
|
-
redirectUrl: 'https://yoursite.com/thank-you'
|
|
205
|
+
metadata: { orderId: 'order_123', source: 'website' }, // Optional
|
|
206
|
+
expiresIn: 24 * 60 * 60 * 1000, // Optional: 24 hours
|
|
207
|
+
redirectUrl: 'https://yoursite.com/thank-you' // Optional
|
|
176
208
|
}
|
|
177
209
|
})
|
|
178
210
|
```
|
|
179
211
|
|
|
180
|
-
##### `generateProductCheckoutUrl(productId, quantity
|
|
212
|
+
##### `generateProductCheckoutUrl(productId, quantity?, customer?, options?): Promise<string>`
|
|
181
213
|
|
|
182
|
-
|
|
214
|
+
Quick method for single product checkout.
|
|
183
215
|
|
|
184
216
|
```ts
|
|
185
|
-
const
|
|
186
|
-
'prod_123',
|
|
187
|
-
2,
|
|
188
|
-
{ email: 'customer@example.com' }
|
|
217
|
+
const checkoutUrl = await torque.generateProductCheckoutUrl(
|
|
218
|
+
'prod_123', // Product ID
|
|
219
|
+
2, // Quantity (default: 1)
|
|
220
|
+
{ email: 'customer@example.com' }, // Customer data (optional)
|
|
221
|
+
{ redirectUrl: 'https://yoursite.com/success' } // Options (optional)
|
|
189
222
|
)
|
|
190
223
|
```
|
|
191
224
|
|
|
192
225
|
##### `generateSubscriptionCheckoutUrl(productId, paymentPlanId, customer?, options?): Promise<string>`
|
|
193
226
|
|
|
194
|
-
Generate
|
|
227
|
+
Generate checkout for subscription products.
|
|
195
228
|
|
|
196
229
|
```ts
|
|
197
|
-
const
|
|
198
|
-
'prod_subscription_123',
|
|
199
|
-
'plan_monthly_123',
|
|
200
|
-
{ email: 'customer@example.com' }
|
|
230
|
+
const checkoutUrl = await torque.generateSubscriptionCheckoutUrl(
|
|
231
|
+
'prod_subscription_123', // Subscription product ID
|
|
232
|
+
'plan_monthly_123', // Payment plan ID
|
|
233
|
+
{ email: 'customer@example.com' },
|
|
234
|
+
{ redirectUrl: 'https://yoursite.com/subscription-success' }
|
|
201
235
|
)
|
|
202
236
|
```
|
|
203
237
|
|
|
204
238
|
##### `validateCart(cart: CartData): Promise<CartValidation>`
|
|
205
239
|
|
|
206
|
-
Validate cart
|
|
240
|
+
Validate cart before checkout to catch errors early.
|
|
207
241
|
|
|
208
242
|
```ts
|
|
209
|
-
const validation = await torque.validateCart(
|
|
243
|
+
const validation = await torque.validateCart({
|
|
244
|
+
items: [{ productId: 'prod_123', quantity: 2 }]
|
|
245
|
+
})
|
|
210
246
|
|
|
211
247
|
if (validation.valid) {
|
|
212
|
-
console.log('Cart is valid
|
|
248
|
+
console.log('Cart is valid')
|
|
249
|
+
console.log('Estimated total:', validation.estimatedTotal)
|
|
213
250
|
} else {
|
|
214
251
|
console.error('Validation errors:', validation.errors)
|
|
252
|
+
console.warn('Warnings:', validation.warnings)
|
|
215
253
|
}
|
|
216
254
|
```
|
|
217
255
|
|
|
218
256
|
##### `getOrderStatus(orderId: string): Promise<OrderStatus>`
|
|
219
257
|
|
|
220
|
-
|
|
258
|
+
Check order status after checkout.
|
|
221
259
|
|
|
222
260
|
```ts
|
|
223
261
|
const order = await torque.getOrderStatus('order_123')
|
|
224
|
-
|
|
262
|
+
|
|
263
|
+
console.log('Status:', order.status) // e.g., 'completed', 'pending', 'failed'
|
|
225
264
|
console.log('Total:', order.totals.total)
|
|
265
|
+
console.log('Payment status:', order.paymentStatus)
|
|
266
|
+
console.log('Items:', order.items)
|
|
226
267
|
```
|
|
227
268
|
|
|
228
269
|
### React Hooks
|
|
229
270
|
|
|
230
271
|
#### `useTorqueCheckout(options?)`
|
|
231
272
|
|
|
232
|
-
React hook for client-side checkout
|
|
273
|
+
React hook for client-side checkout with built-in state management.
|
|
233
274
|
|
|
234
275
|
```tsx
|
|
235
276
|
import { useTorqueCheckout } from 'torque-checkout/react'
|
|
@@ -245,23 +286,47 @@ function CheckoutButton() {
|
|
|
245
286
|
error,
|
|
246
287
|
checkoutUrl
|
|
247
288
|
} = useTorqueCheckout({
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
289
|
+
// Optional: explicit config (or use env vars)
|
|
290
|
+
// config: {
|
|
291
|
+
// businessId: 'your_business_id',
|
|
292
|
+
// apiKey: 'your_api_key'
|
|
293
|
+
// },
|
|
294
|
+
autoRedirect: true, // Auto-redirect to checkout URL
|
|
295
|
+
onSuccess: (url) => {
|
|
296
|
+
console.log('Checkout URL generated:', url)
|
|
297
|
+
// Optional: track analytics
|
|
251
298
|
},
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
299
|
+
onError: (error) => {
|
|
300
|
+
console.error('Checkout error:', error)
|
|
301
|
+
// Optional: show error toast
|
|
302
|
+
}
|
|
256
303
|
})
|
|
257
304
|
|
|
258
|
-
|
|
305
|
+
const handleCheckout = async () => {
|
|
306
|
+
const url = await generateProductCheckout('prod_123', 1, {
|
|
307
|
+
email: 'customer@example.com'
|
|
308
|
+
})
|
|
309
|
+
// If autoRedirect is false, you can handle the URL manually
|
|
310
|
+
if (url && !autoRedirect) {
|
|
311
|
+
window.location.href = url
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return (
|
|
316
|
+
<div>
|
|
317
|
+
<button onClick={handleCheckout} disabled={isLoading}>
|
|
318
|
+
{isLoading ? 'Processing...' : 'Checkout'}
|
|
319
|
+
</button>
|
|
320
|
+
{error && <p className="error">{error.message}</p>}
|
|
321
|
+
{checkoutUrl && <p>Checkout URL: {checkoutUrl}</p>}
|
|
322
|
+
</div>
|
|
323
|
+
)
|
|
259
324
|
}
|
|
260
325
|
```
|
|
261
326
|
|
|
262
327
|
#### `useCart()`
|
|
263
328
|
|
|
264
|
-
|
|
329
|
+
Shopping cart state management hook.
|
|
265
330
|
|
|
266
331
|
```tsx
|
|
267
332
|
import { useCart } from 'torque-checkout/react'
|
|
@@ -279,16 +344,26 @@ function ShoppingCart() {
|
|
|
279
344
|
|
|
280
345
|
return (
|
|
281
346
|
<div>
|
|
282
|
-
<h2>Cart ({getItemCount()} items)</h2>
|
|
283
|
-
|
|
347
|
+
<h2>Shopping Cart ({getItemCount()} items)</h2>
|
|
348
|
+
|
|
284
349
|
{items.map(item => (
|
|
285
|
-
<div key={`${item.productId}-${item.variant}`}>
|
|
286
|
-
{item.productId}
|
|
350
|
+
<div key={`${item.productId}-${item.variant || ''}`}>
|
|
351
|
+
<span>Product {item.productId}</span>
|
|
352
|
+
<span>Quantity: {item.quantity}</span>
|
|
353
|
+
<button onClick={() => updateQuantity(item.productId, item.quantity + 1, item.variant)}>
|
|
354
|
+
+
|
|
355
|
+
</button>
|
|
356
|
+
<button onClick={() => updateQuantity(item.productId, item.quantity - 1, item.variant)}>
|
|
357
|
+
-
|
|
358
|
+
</button>
|
|
287
359
|
<button onClick={() => removeItem(item.productId, item.variant)}>
|
|
288
360
|
Remove
|
|
289
361
|
</button>
|
|
290
362
|
</div>
|
|
291
363
|
))}
|
|
364
|
+
|
|
365
|
+
<p>Total: ${getTotal().toFixed(2)}</p>
|
|
366
|
+
<button onClick={clearCart}>Clear Cart</button>
|
|
292
367
|
</div>
|
|
293
368
|
)
|
|
294
369
|
}
|
|
@@ -296,65 +371,80 @@ function ShoppingCart() {
|
|
|
296
371
|
|
|
297
372
|
### Next.js Server Utilities
|
|
298
373
|
|
|
299
|
-
#### `generateCheckoutUrl(cart, config?)`
|
|
300
|
-
|
|
301
|
-
Generate checkout URL on the server.
|
|
302
|
-
|
|
303
|
-
```ts
|
|
304
|
-
// app/api/checkout/route.ts
|
|
305
|
-
import { generateCheckoutUrl } from 'torque-checkout/nextjs'
|
|
306
|
-
|
|
307
|
-
export async function POST(request: Request) {
|
|
308
|
-
const cart = await request.json()
|
|
309
|
-
const url = await generateCheckoutUrl(cart)
|
|
310
|
-
return Response.json({ checkoutUrl: url })
|
|
311
|
-
}
|
|
312
|
-
```
|
|
313
|
-
|
|
314
374
|
#### `handleCheckoutRequest(options?)`
|
|
315
375
|
|
|
316
|
-
|
|
376
|
+
Complete API route handler with error handling.
|
|
317
377
|
|
|
318
378
|
```ts
|
|
319
379
|
// app/api/checkout/route.ts
|
|
320
380
|
import { handleCheckoutRequest } from 'torque-checkout/nextjs'
|
|
321
381
|
|
|
322
382
|
export const POST = handleCheckoutRequest({
|
|
323
|
-
onSuccess: async (
|
|
324
|
-
// Log
|
|
325
|
-
console.log('Checkout generated:',
|
|
383
|
+
onSuccess: async (checkoutUrl, cart) => {
|
|
384
|
+
// Optional: Log, track analytics, send notifications
|
|
385
|
+
console.log('Checkout generated:', checkoutUrl)
|
|
386
|
+
await logCheckoutEvent(cart)
|
|
326
387
|
},
|
|
327
388
|
onError: async (error, cart) => {
|
|
328
|
-
//
|
|
389
|
+
// Optional: Error logging, notifications
|
|
329
390
|
console.error('Checkout error:', error)
|
|
391
|
+
await logError(error, cart)
|
|
330
392
|
}
|
|
331
393
|
})
|
|
332
394
|
```
|
|
333
395
|
|
|
334
396
|
#### `handleWebhook(options?)`
|
|
335
397
|
|
|
336
|
-
|
|
398
|
+
Webhook handler for order events.
|
|
337
399
|
|
|
338
400
|
```ts
|
|
339
401
|
// app/api/webhooks/torque/route.ts
|
|
340
402
|
import { handleWebhook } from 'torque-checkout/nextjs'
|
|
341
403
|
|
|
342
404
|
export const POST = handleWebhook({
|
|
343
|
-
secret: process.env.TORQUE_WEBHOOK_SECRET,
|
|
405
|
+
secret: process.env.TORQUE_WEBHOOK_SECRET, // Optional: verify signatures
|
|
344
406
|
onOrderCompleted: async (event) => {
|
|
345
407
|
// Fulfill order
|
|
346
408
|
await fulfillOrder(event.orderId!)
|
|
409
|
+
await sendConfirmationEmail(event.data.customerEmail)
|
|
347
410
|
},
|
|
348
411
|
onOrderFailed: async (event) => {
|
|
349
|
-
// Handle failed
|
|
412
|
+
// Handle failed payment
|
|
350
413
|
await notifyCustomer(event.orderId!)
|
|
414
|
+
},
|
|
415
|
+
onSubscriptionCreated: async (event) => {
|
|
416
|
+
// Activate subscription
|
|
417
|
+
await activateSubscription(event.subscriptionId!)
|
|
351
418
|
}
|
|
352
419
|
})
|
|
353
420
|
```
|
|
354
421
|
|
|
355
|
-
|
|
422
|
+
#### `generateCheckoutUrl(cart, config?)`
|
|
423
|
+
|
|
424
|
+
Server-side checkout URL generation.
|
|
425
|
+
|
|
426
|
+
```ts
|
|
427
|
+
// app/api/checkout/route.ts
|
|
428
|
+
import { generateCheckoutUrl } from 'torque-checkout/nextjs'
|
|
429
|
+
|
|
430
|
+
export async function POST(request: Request) {
|
|
431
|
+
const cart = await request.json()
|
|
432
|
+
|
|
433
|
+
try {
|
|
434
|
+
const checkoutUrl = await generateCheckoutUrl(cart)
|
|
435
|
+
return Response.json({ checkoutUrl })
|
|
436
|
+
} catch (error: any) {
|
|
437
|
+
return Response.json(
|
|
438
|
+
{ error: error.message, code: error.code },
|
|
439
|
+
{ status: error.statusCode || 500 }
|
|
440
|
+
)
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
## Complete Examples
|
|
356
446
|
|
|
357
|
-
###
|
|
447
|
+
### E-Commerce Product Page
|
|
358
448
|
|
|
359
449
|
```tsx
|
|
360
450
|
// app/products/[id]/page.tsx
|
|
@@ -364,26 +454,26 @@ import { useTorqueCheckout, useCart } from 'torque-checkout/react'
|
|
|
364
454
|
import { useState } from 'react'
|
|
365
455
|
|
|
366
456
|
export default function ProductPage({ params }: { params: { id: string } }) {
|
|
367
|
-
const
|
|
457
|
+
const cart = useCart()
|
|
368
458
|
const { generateProductCheckout, isLoading } = useTorqueCheckout({
|
|
369
459
|
autoRedirect: true
|
|
370
460
|
})
|
|
371
461
|
const [quantity, setQuantity] = useState(1)
|
|
372
462
|
|
|
373
463
|
const handleAddToCart = () => {
|
|
374
|
-
addItem({
|
|
464
|
+
cart.addItem({
|
|
375
465
|
productId: params.id,
|
|
376
466
|
quantity,
|
|
377
|
-
price: 29.99
|
|
467
|
+
price: 29.99
|
|
378
468
|
})
|
|
379
469
|
}
|
|
380
470
|
|
|
381
|
-
const
|
|
471
|
+
const handleBuyNow = async () => {
|
|
382
472
|
await generateProductCheckout(
|
|
383
473
|
params.id,
|
|
384
474
|
quantity,
|
|
385
475
|
{
|
|
386
|
-
email: 'customer@example.com' // Get from
|
|
476
|
+
email: 'customer@example.com' // Get from auth/session
|
|
387
477
|
}
|
|
388
478
|
)
|
|
389
479
|
}
|
|
@@ -391,6 +481,8 @@ export default function ProductPage({ params }: { params: { id: string } }) {
|
|
|
391
481
|
return (
|
|
392
482
|
<div>
|
|
393
483
|
<h1>Product {params.id}</h1>
|
|
484
|
+
<p>$29.99</p>
|
|
485
|
+
|
|
394
486
|
<div>
|
|
395
487
|
<label>Quantity:</label>
|
|
396
488
|
<input
|
|
@@ -400,51 +492,82 @@ export default function ProductPage({ params }: { params: { id: string } }) {
|
|
|
400
492
|
min="1"
|
|
401
493
|
/>
|
|
402
494
|
</div>
|
|
403
|
-
|
|
404
|
-
<button onClick={
|
|
405
|
-
|
|
495
|
+
|
|
496
|
+
<button onClick={handleAddToCart}>
|
|
497
|
+
Add to Cart ({cart.getItemCount()} items)
|
|
498
|
+
</button>
|
|
499
|
+
|
|
500
|
+
<button onClick={handleBuyNow} disabled={isLoading}>
|
|
501
|
+
{isLoading ? 'Processing...' : 'Buy Now'}
|
|
406
502
|
</button>
|
|
407
|
-
<p>Cart: {getItemCount()} items</p>
|
|
408
503
|
</div>
|
|
409
504
|
)
|
|
410
505
|
}
|
|
411
506
|
```
|
|
412
507
|
|
|
413
|
-
###
|
|
508
|
+
### Shopping Cart with Checkout
|
|
414
509
|
|
|
415
|
-
```
|
|
416
|
-
//
|
|
417
|
-
|
|
418
|
-
import { createTorqueCheckoutFromEnv } from 'torque-checkout'
|
|
510
|
+
```tsx
|
|
511
|
+
// app/cart/page.tsx
|
|
512
|
+
'use client'
|
|
419
513
|
|
|
420
|
-
|
|
421
|
-
req: NextApiRequest,
|
|
422
|
-
res: NextApiResponse
|
|
423
|
-
) {
|
|
424
|
-
if (req.method !== 'POST') {
|
|
425
|
-
return res.status(405).json({ error: 'Method not allowed' })
|
|
426
|
-
}
|
|
514
|
+
import { useTorqueCheckout, useCart } from 'torque-checkout/react'
|
|
427
515
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
516
|
+
export default function CartPage() {
|
|
517
|
+
const cart = useCart()
|
|
518
|
+
const { generateCheckout, isLoading } = useTorqueCheckout()
|
|
431
519
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
520
|
+
const handleCheckout = async () => {
|
|
521
|
+
const url = await generateCheckout({
|
|
522
|
+
items: cart.items,
|
|
523
|
+
customer: {
|
|
524
|
+
email: 'customer@example.com' // Get from auth
|
|
525
|
+
},
|
|
526
|
+
options: {
|
|
527
|
+
redirectUrl: window.location.origin + '/thank-you'
|
|
528
|
+
}
|
|
437
529
|
})
|
|
530
|
+
|
|
531
|
+
if (url) {
|
|
532
|
+
window.location.href = url
|
|
533
|
+
}
|
|
438
534
|
}
|
|
535
|
+
|
|
536
|
+
if (cart.items.length === 0) {
|
|
537
|
+
return <p>Your cart is empty</p>
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
return (
|
|
541
|
+
<div>
|
|
542
|
+
<h1>Shopping Cart</h1>
|
|
543
|
+
{cart.items.map(item => (
|
|
544
|
+
<div key={`${item.productId}-${item.variant || ''}`}>
|
|
545
|
+
<span>Product {item.productId}</span>
|
|
546
|
+
<span>Qty: {item.quantity}</span>
|
|
547
|
+
<span>${((item.price || 0) * item.quantity).toFixed(2)}</span>
|
|
548
|
+
<button onClick={() => cart.removeItem(item.productId, item.variant)}>
|
|
549
|
+
Remove
|
|
550
|
+
</button>
|
|
551
|
+
</div>
|
|
552
|
+
))}
|
|
553
|
+
<div>
|
|
554
|
+
<strong>Total: ${cart.getTotal().toFixed(2)}</strong>
|
|
555
|
+
</div>
|
|
556
|
+
<button onClick={handleCheckout} disabled={isLoading}>
|
|
557
|
+
{isLoading ? 'Processing...' : 'Proceed to Checkout'}
|
|
558
|
+
</button>
|
|
559
|
+
</div>
|
|
560
|
+
)
|
|
439
561
|
}
|
|
440
562
|
```
|
|
441
563
|
|
|
442
|
-
### Server Component (Next.js 13+)
|
|
564
|
+
### Server Component Checkout (Next.js 13+)
|
|
443
565
|
|
|
444
566
|
```tsx
|
|
445
567
|
// app/checkout/page.tsx
|
|
446
568
|
import { createTorqueCheckoutFromEnv } from 'torque-checkout'
|
|
447
569
|
import { redirect } from 'next/navigation'
|
|
570
|
+
import { cookies } from 'next/headers'
|
|
448
571
|
|
|
449
572
|
export default async function CheckoutPage({
|
|
450
573
|
searchParams
|
|
@@ -453,40 +576,104 @@ export default async function CheckoutPage({
|
|
|
453
576
|
}) {
|
|
454
577
|
const torque = createTorqueCheckoutFromEnv()
|
|
455
578
|
|
|
579
|
+
// Get customer email from session/cookies
|
|
580
|
+
const customerEmail = cookies().get('user_email')?.value || 'guest@example.com'
|
|
581
|
+
|
|
456
582
|
const checkoutUrl = await torque.generateProductCheckoutUrl(
|
|
457
583
|
searchParams.productId,
|
|
458
584
|
Number(searchParams.quantity) || 1,
|
|
459
|
-
{
|
|
460
|
-
email: 'customer@example.com' // Get from session
|
|
461
|
-
}
|
|
585
|
+
{ email: customerEmail }
|
|
462
586
|
)
|
|
463
587
|
|
|
464
588
|
redirect(checkoutUrl)
|
|
465
589
|
}
|
|
466
590
|
```
|
|
467
591
|
|
|
468
|
-
|
|
592
|
+
### Pages Router API Route
|
|
593
|
+
|
|
594
|
+
```ts
|
|
595
|
+
// pages/api/checkout.ts
|
|
596
|
+
import type { NextApiRequest, NextApiResponse } from 'next'
|
|
597
|
+
import { createTorqueCheckoutFromEnv } from 'torque-checkout'
|
|
598
|
+
|
|
599
|
+
export default async function handler(
|
|
600
|
+
req: NextApiRequest,
|
|
601
|
+
res: NextApiResponse
|
|
602
|
+
) {
|
|
603
|
+
if (req.method !== 'POST') {
|
|
604
|
+
return res.status(405).json({ error: 'Method not allowed' })
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
try {
|
|
608
|
+
const torque = createTorqueCheckoutFromEnv()
|
|
609
|
+
const checkoutUrl = await torque.generateCartCheckoutUrl(req.body)
|
|
610
|
+
|
|
611
|
+
res.status(200).json({ checkoutUrl })
|
|
612
|
+
} catch (error: any) {
|
|
613
|
+
res.status(error.statusCode || 500).json({
|
|
614
|
+
error: error.message,
|
|
615
|
+
code: error.code
|
|
616
|
+
})
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
```
|
|
620
|
+
|
|
621
|
+
## TypeScript
|
|
469
622
|
|
|
470
|
-
|
|
623
|
+
Full TypeScript support with comprehensive types:
|
|
471
624
|
|
|
472
625
|
```ts
|
|
473
626
|
import type {
|
|
627
|
+
// Cart types
|
|
474
628
|
CartItem,
|
|
475
629
|
CartData,
|
|
476
|
-
CustomerData,
|
|
477
630
|
CartOptions,
|
|
478
|
-
CheckoutResponse,
|
|
479
|
-
OrderStatus,
|
|
480
631
|
CartValidation,
|
|
632
|
+
|
|
633
|
+
// Customer types
|
|
634
|
+
CustomerData,
|
|
635
|
+
|
|
636
|
+
// Order types
|
|
637
|
+
OrderStatus,
|
|
638
|
+
CheckoutResponse,
|
|
639
|
+
|
|
640
|
+
// Subscription types
|
|
481
641
|
Subscription,
|
|
482
642
|
SubscriptionProduct,
|
|
483
643
|
PaymentPlan,
|
|
644
|
+
CreateSubscriptionData,
|
|
645
|
+
UpdateSubscriptionData,
|
|
646
|
+
|
|
647
|
+
// Config types
|
|
484
648
|
TorqueConfig,
|
|
485
649
|
TorqueCheckoutError
|
|
486
650
|
} from 'torque-checkout'
|
|
487
651
|
```
|
|
488
652
|
|
|
489
|
-
|
|
653
|
+
### Type-Safe Usage
|
|
654
|
+
|
|
655
|
+
```ts
|
|
656
|
+
import { createTorqueCheckout } from 'torque-checkout'
|
|
657
|
+
import type { CartData, CustomerData } from 'torque-checkout'
|
|
658
|
+
|
|
659
|
+
const torque = createTorqueCheckout({
|
|
660
|
+
businessId: process.env.TORQUE_BUSINESS_ID!,
|
|
661
|
+
apiKey: process.env.TORQUE_API_KEY!
|
|
662
|
+
})
|
|
663
|
+
|
|
664
|
+
const cart: CartData = {
|
|
665
|
+
items: [
|
|
666
|
+
{ productId: 'prod_123', quantity: 1 }
|
|
667
|
+
],
|
|
668
|
+
customer: {
|
|
669
|
+
email: 'customer@example.com'
|
|
670
|
+
} as CustomerData
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const url: string = await torque.generateCartCheckoutUrl(cart)
|
|
674
|
+
```
|
|
675
|
+
|
|
676
|
+
## Error Handling
|
|
490
677
|
|
|
491
678
|
The SDK uses custom error classes for better error handling:
|
|
492
679
|
|
|
@@ -504,54 +691,200 @@ try {
|
|
|
504
691
|
// Handle specific error codes
|
|
505
692
|
switch (error.code) {
|
|
506
693
|
case 'VALIDATION_ERROR':
|
|
507
|
-
//
|
|
694
|
+
// Show validation errors to user
|
|
695
|
+
showErrors(error.details?.errors)
|
|
508
696
|
break
|
|
509
697
|
case 'BUSINESS_NOT_FOUND':
|
|
510
|
-
//
|
|
698
|
+
// Check business ID configuration
|
|
699
|
+
console.error('Invalid business ID')
|
|
511
700
|
break
|
|
512
701
|
case 'TIMEOUT':
|
|
513
|
-
//
|
|
702
|
+
// Retry or show timeout message
|
|
703
|
+
retryCheckout()
|
|
704
|
+
break
|
|
705
|
+
case 'NETWORK_ERROR':
|
|
706
|
+
// Check internet connection
|
|
707
|
+
showNetworkError()
|
|
514
708
|
break
|
|
515
709
|
default:
|
|
516
|
-
//
|
|
710
|
+
// Generic error handling
|
|
711
|
+
showGenericError(error.message)
|
|
517
712
|
}
|
|
713
|
+
} else {
|
|
714
|
+
// Unknown error
|
|
715
|
+
console.error('Unexpected error:', error)
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
```
|
|
719
|
+
|
|
720
|
+
### Common Error Codes
|
|
721
|
+
|
|
722
|
+
| Code | Description | Solution |
|
|
723
|
+
|------|-------------|----------|
|
|
724
|
+
| `VALIDATION_ERROR` | Cart validation failed | Check cart items and customer data |
|
|
725
|
+
| `BUSINESS_NOT_FOUND` | Invalid business ID | Verify `TORQUE_BUSINESS_ID` |
|
|
726
|
+
| `INVALID_API_KEY` | Invalid API key | Verify `TORQUE_API_KEY` |
|
|
727
|
+
| `TIMEOUT` | Request timed out | Check network, increase timeout |
|
|
728
|
+
| `NETWORK_ERROR` | Network request failed | Check internet connection |
|
|
729
|
+
| `ORDER_NOT_FOUND` | Order doesn't exist | Verify order ID |
|
|
730
|
+
|
|
731
|
+
## Security Best Practices
|
|
732
|
+
|
|
733
|
+
1. **Never expose API keys in client-side code**
|
|
734
|
+
```ts
|
|
735
|
+
// BAD - Don't do this
|
|
736
|
+
const torque = createTorqueCheckout({
|
|
737
|
+
businessId: 'business_123',
|
|
738
|
+
apiKey: 'sk_live_...' // Exposed in browser!
|
|
739
|
+
})
|
|
740
|
+
|
|
741
|
+
// GOOD - Use server-side API routes
|
|
742
|
+
// app/api/checkout/route.ts
|
|
743
|
+
export const POST = handleCheckoutRequest()
|
|
744
|
+
```
|
|
745
|
+
|
|
746
|
+
2. **Use environment variables**
|
|
747
|
+
```env
|
|
748
|
+
# .env.local (never commit)
|
|
749
|
+
TORQUE_BUSINESS_ID=your_business_id
|
|
750
|
+
TORQUE_API_KEY=your_api_key
|
|
751
|
+
```
|
|
752
|
+
|
|
753
|
+
3. **Validate user input**
|
|
754
|
+
```ts
|
|
755
|
+
// Always validate before sending to API
|
|
756
|
+
if (!cart.items || cart.items.length === 0) {
|
|
757
|
+
throw new Error('Cart is empty')
|
|
758
|
+
}
|
|
759
|
+
```
|
|
760
|
+
|
|
761
|
+
4. **Use HTTPS in production**
|
|
762
|
+
- Always use HTTPS for checkout URLs
|
|
763
|
+
- Never send sensitive data over HTTP
|
|
764
|
+
|
|
765
|
+
5. **Verify webhook signatures**
|
|
766
|
+
```ts
|
|
767
|
+
export const POST = handleWebhook({
|
|
768
|
+
secret: process.env.TORQUE_WEBHOOK_SECRET
|
|
769
|
+
})
|
|
770
|
+
```
|
|
771
|
+
|
|
772
|
+
6. **Set payment wallet in business settings**
|
|
773
|
+
- Payments automatically go to your configured wallet
|
|
774
|
+
- No need to handle wallet addresses in code
|
|
775
|
+
|
|
776
|
+
## Troubleshooting
|
|
777
|
+
|
|
778
|
+
### Issue: "TorqueCheckout not initialized"
|
|
779
|
+
|
|
780
|
+
**Solution**: Make sure environment variables are set:
|
|
781
|
+
```bash
|
|
782
|
+
# Check if variables are loaded
|
|
783
|
+
echo $TORQUE_BUSINESS_ID
|
|
784
|
+
echo $TORQUE_API_KEY
|
|
785
|
+
```
|
|
786
|
+
|
|
787
|
+
Or provide explicit config:
|
|
788
|
+
```ts
|
|
789
|
+
const torque = createTorqueCheckout({
|
|
790
|
+
businessId: 'your_business_id',
|
|
791
|
+
apiKey: 'your_api_key'
|
|
792
|
+
})
|
|
793
|
+
```
|
|
794
|
+
|
|
795
|
+
### Issue: "Request timeout"
|
|
796
|
+
|
|
797
|
+
**Solution**: Increase timeout or check network:
|
|
798
|
+
```ts
|
|
799
|
+
const torque = createTorqueCheckout({
|
|
800
|
+
businessId: process.env.TORQUE_BUSINESS_ID!,
|
|
801
|
+
apiKey: process.env.TORQUE_API_KEY!,
|
|
802
|
+
timeout: 60000 // 60 seconds
|
|
803
|
+
})
|
|
804
|
+
```
|
|
805
|
+
|
|
806
|
+
### Issue: "Business not found"
|
|
807
|
+
|
|
808
|
+
**Solution**:
|
|
809
|
+
1. Verify business ID in [Business Settings](https://app.torque.fi/business/settings)
|
|
810
|
+
2. Check environment variable is correct
|
|
811
|
+
3. Ensure business profile is saved and active
|
|
812
|
+
|
|
813
|
+
### Issue: React hooks not working
|
|
814
|
+
|
|
815
|
+
**Solution**: Make sure you're using the React entry point:
|
|
816
|
+
```tsx
|
|
817
|
+
// Correct
|
|
818
|
+
import { useTorqueCheckout } from 'torque-checkout/react'
|
|
819
|
+
|
|
820
|
+
// Wrong
|
|
821
|
+
import { useTorqueCheckout } from 'torque-checkout'
|
|
822
|
+
```
|
|
823
|
+
|
|
824
|
+
### Issue: TypeScript errors
|
|
825
|
+
|
|
826
|
+
**Solution**: Make sure TypeScript can find the types:
|
|
827
|
+
```json
|
|
828
|
+
// tsconfig.json
|
|
829
|
+
{
|
|
830
|
+
"compilerOptions": {
|
|
831
|
+
"moduleResolution": "node",
|
|
832
|
+
"esModuleInterop": true
|
|
518
833
|
}
|
|
519
834
|
}
|
|
520
835
|
```
|
|
521
836
|
|
|
522
|
-
##
|
|
837
|
+
## Migration Guide
|
|
838
|
+
|
|
839
|
+
### From v1.x to v2.0.0
|
|
523
840
|
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
841
|
+
v2.0.0 is **backward compatible** with v1.x. No code changes required!
|
|
842
|
+
|
|
843
|
+
**New features available (optional):**
|
|
844
|
+
- React hooks: `useTorqueCheckout()`, `useCart()`
|
|
845
|
+
- Next.js utilities: `handleCheckoutRequest()`, `handleWebhook()`
|
|
846
|
+
- Environment variable support: `createTorqueCheckoutFromEnv()`
|
|
847
|
+
|
|
848
|
+
**Migration example:**
|
|
849
|
+
|
|
850
|
+
```ts
|
|
851
|
+
// v1.x (still works)
|
|
852
|
+
import { TorqueCheckout } from 'torque-checkout'
|
|
853
|
+
const torque = new TorqueCheckout({ businessId, apiKey })
|
|
854
|
+
|
|
855
|
+
// v2.0.0 (new, optional)
|
|
856
|
+
import { createTorqueCheckoutFromEnv } from 'torque-checkout'
|
|
857
|
+
const torque = createTorqueCheckoutFromEnv() // Uses env vars
|
|
858
|
+
```
|
|
529
859
|
|
|
530
|
-
##
|
|
860
|
+
## API Reference
|
|
531
861
|
|
|
532
|
-
|
|
862
|
+
For complete API documentation, visit:
|
|
863
|
+
- **Full API Docs**: [https://docs.torque.fi/api](https://docs.torque.fi/api)
|
|
864
|
+
- **Integration Guide**: [https://docs.torque.fi/integrations](https://docs.torque.fi/integrations)
|
|
533
865
|
|
|
534
|
-
##
|
|
866
|
+
## Support
|
|
535
867
|
|
|
536
868
|
- **Documentation**: [https://docs.torque.fi](https://docs.torque.fi)
|
|
537
869
|
- **Email**: hello@torque.fi
|
|
538
870
|
- **GitHub Issues**: [https://github.com/torque-fi/torque-checkout/issues](https://github.com/torque-fi/torque-checkout/issues)
|
|
871
|
+
- **Business Dashboard**: [https://app.torque.fi/business/settings](https://app.torque.fi/business/settings)
|
|
539
872
|
|
|
540
|
-
##
|
|
873
|
+
## License
|
|
541
874
|
|
|
542
875
|
MIT © [Torque](https://torque.fi)
|
|
543
876
|
|
|
544
|
-
##
|
|
877
|
+
## Getting Started Checklist
|
|
545
878
|
|
|
546
|
-
- [ ] Install
|
|
547
|
-
- [ ] Create
|
|
879
|
+
- [ ] Install: `npm install torque-checkout`
|
|
880
|
+
- [ ] Create business profile: [app.torque.fi/business/settings](https://app.torque.fi/business/settings)
|
|
548
881
|
- [ ] Set payment wallet address in business settings
|
|
549
882
|
- [ ] Copy Business ID and API Key
|
|
550
883
|
- [ ] Add environment variables to `.env.local`
|
|
551
|
-
- [ ] Import and use the SDK
|
|
884
|
+
- [ ] Import and use the SDK
|
|
552
885
|
- [ ] Test checkout flow
|
|
553
|
-
- [ ] Deploy to production!
|
|
886
|
+
- [ ] Deploy to production!
|
|
554
887
|
|
|
555
888
|
---
|
|
556
889
|
|
|
557
|
-
**Made with
|
|
890
|
+
**Made with love by [Torque](https://torque.fi)**
|