omni-sync-sdk 0.2.0 → 0.4.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 +1430 -191
- package/dist/index.d.mts +186 -27
- package/dist/index.d.ts +186 -27
- package/dist/index.js +481 -37
- package/dist/index.mjs +481 -37
- package/package.json +24 -3
package/README.md
CHANGED
|
@@ -1,191 +1,1430 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
SDK for
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
# or
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
//
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
//
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
'
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
```
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
1
|
+
# omni-sync-sdk
|
|
2
|
+
|
|
3
|
+
Official SDK for building e-commerce storefronts with **OmniSync Platform**.
|
|
4
|
+
|
|
5
|
+
This SDK provides a complete solution for vibe-coded sites, AI-built stores (Cursor, Lovable, v0), and custom storefronts to connect to OmniSync's unified commerce API.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install omni-sync-sdk
|
|
11
|
+
# or
|
|
12
|
+
pnpm add omni-sync-sdk
|
|
13
|
+
# or
|
|
14
|
+
yarn add omni-sync-sdk
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Quick Start
|
|
20
|
+
|
|
21
|
+
### For Vibe-Coded Sites (Recommended)
|
|
22
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
import { OmniSyncClient } from 'omni-sync-sdk';
|
|
25
|
+
|
|
26
|
+
// Initialize with your Connection ID
|
|
27
|
+
const omni = new OmniSyncClient({
|
|
28
|
+
connectionId: 'vc_YOUR_CONNECTION_ID',
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Fetch products
|
|
32
|
+
const { data: products } = await omni.getProducts();
|
|
33
|
+
|
|
34
|
+
// Create a cart
|
|
35
|
+
const cart = await omni.createCart();
|
|
36
|
+
|
|
37
|
+
// Add item to cart
|
|
38
|
+
await omni.addToCart(cart.id, {
|
|
39
|
+
productId: products[0].id,
|
|
40
|
+
quantity: 1,
|
|
41
|
+
});
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## Complete Store Setup
|
|
47
|
+
|
|
48
|
+
### Step 1: Create the OmniSync Client
|
|
49
|
+
|
|
50
|
+
Create a file `lib/omni-sync.ts`:
|
|
51
|
+
|
|
52
|
+
```typescript
|
|
53
|
+
import { OmniSyncClient } from 'omni-sync-sdk';
|
|
54
|
+
|
|
55
|
+
export const omni = new OmniSyncClient({
|
|
56
|
+
connectionId: 'vc_YOUR_CONNECTION_ID', // Your Connection ID from OmniSync
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// ----- Cart Helpers -----
|
|
60
|
+
|
|
61
|
+
export function getCartId(): string | null {
|
|
62
|
+
if (typeof window === 'undefined') return null;
|
|
63
|
+
return localStorage.getItem('cartId');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function setCartId(id: string): void {
|
|
67
|
+
localStorage.setItem('cartId', id);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function clearCartId(): void {
|
|
71
|
+
localStorage.removeItem('cartId');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ----- Customer Token Helpers -----
|
|
75
|
+
|
|
76
|
+
export function setCustomerToken(token: string | null): void {
|
|
77
|
+
if (token) {
|
|
78
|
+
localStorage.setItem('customerToken', token);
|
|
79
|
+
omni.setCustomerToken(token);
|
|
80
|
+
} else {
|
|
81
|
+
localStorage.removeItem('customerToken');
|
|
82
|
+
omni.clearCustomerToken();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function restoreCustomerToken(): string | null {
|
|
87
|
+
const token = localStorage.getItem('customerToken');
|
|
88
|
+
if (token) omni.setCustomerToken(token);
|
|
89
|
+
return token;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function isLoggedIn(): boolean {
|
|
93
|
+
return !!localStorage.getItem('customerToken');
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## API Reference
|
|
100
|
+
|
|
101
|
+
### Products
|
|
102
|
+
|
|
103
|
+
#### Get Products (with pagination)
|
|
104
|
+
|
|
105
|
+
```typescript
|
|
106
|
+
import { omni } from '@/lib/omni-sync';
|
|
107
|
+
import type { Product, PaginatedResponse } from 'omni-sync-sdk';
|
|
108
|
+
|
|
109
|
+
const response: PaginatedResponse<Product> = await omni.getProducts({
|
|
110
|
+
page: 1,
|
|
111
|
+
limit: 12,
|
|
112
|
+
search: 'shirt', // Optional: search by name
|
|
113
|
+
status: 'active', // Optional: 'active' | 'draft' | 'archived'
|
|
114
|
+
type: 'SIMPLE', // Optional: 'SIMPLE' | 'VARIABLE'
|
|
115
|
+
sortBy: 'createdAt', // Optional: 'name' | 'createdAt' | 'updatedAt' | 'basePrice'
|
|
116
|
+
sortOrder: 'desc', // Optional: 'asc' | 'desc'
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
console.log(response.data); // Product[]
|
|
120
|
+
console.log(response.meta.total); // Total number of products
|
|
121
|
+
console.log(response.meta.totalPages); // Total pages
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
#### Get Single Product
|
|
125
|
+
|
|
126
|
+
```typescript
|
|
127
|
+
const product: Product = await omni.getProduct('product_id');
|
|
128
|
+
|
|
129
|
+
console.log(product.name);
|
|
130
|
+
console.log(product.basePrice);
|
|
131
|
+
console.log(product.salePrice); // null if no sale
|
|
132
|
+
console.log(product.images); // ProductImage[]
|
|
133
|
+
console.log(product.variants); // ProductVariant[] (for VARIABLE products)
|
|
134
|
+
console.log(product.inventory); // { total, reserved, available }
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
#### Product Type Definition
|
|
138
|
+
|
|
139
|
+
```typescript
|
|
140
|
+
interface Product {
|
|
141
|
+
id: string;
|
|
142
|
+
name: string;
|
|
143
|
+
description?: string | null;
|
|
144
|
+
sku: string;
|
|
145
|
+
basePrice: number;
|
|
146
|
+
salePrice?: number | null;
|
|
147
|
+
status: 'active' | 'draft' | 'archived';
|
|
148
|
+
type: 'SIMPLE' | 'VARIABLE';
|
|
149
|
+
images?: ProductImage[];
|
|
150
|
+
inventory?: InventoryInfo | null;
|
|
151
|
+
variants?: ProductVariant[];
|
|
152
|
+
categories?: string[];
|
|
153
|
+
tags?: string[];
|
|
154
|
+
createdAt: string;
|
|
155
|
+
updatedAt: string;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
interface ProductImage {
|
|
159
|
+
url: string;
|
|
160
|
+
position?: number;
|
|
161
|
+
isMain?: boolean;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
interface ProductVariant {
|
|
165
|
+
id: string;
|
|
166
|
+
sku?: string | null;
|
|
167
|
+
name?: string | null;
|
|
168
|
+
price?: number | null;
|
|
169
|
+
salePrice?: number | null;
|
|
170
|
+
attributes?: Record<string, string>;
|
|
171
|
+
inventory?: InventoryInfo | null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
interface InventoryInfo {
|
|
175
|
+
total: number;
|
|
176
|
+
reserved: number;
|
|
177
|
+
available: number;
|
|
178
|
+
}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
### Cart
|
|
184
|
+
|
|
185
|
+
#### Create Cart
|
|
186
|
+
|
|
187
|
+
```typescript
|
|
188
|
+
const cart = await omni.createCart();
|
|
189
|
+
setCartId(cart.id); // Save to localStorage
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
#### Get Cart
|
|
193
|
+
|
|
194
|
+
```typescript
|
|
195
|
+
const cartId = getCartId();
|
|
196
|
+
if (cartId) {
|
|
197
|
+
const cart = await omni.getCart(cartId);
|
|
198
|
+
console.log(cart.items); // CartItem[]
|
|
199
|
+
console.log(cart.itemCount); // Total items
|
|
200
|
+
console.log(cart.subtotal); // Subtotal amount
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
#### Add to Cart
|
|
205
|
+
|
|
206
|
+
```typescript
|
|
207
|
+
const cart = await omni.addToCart(cartId, {
|
|
208
|
+
productId: 'product_id',
|
|
209
|
+
variantId: 'variant_id', // Optional: for VARIABLE products
|
|
210
|
+
quantity: 2,
|
|
211
|
+
notes: 'Gift wrap please', // Optional
|
|
212
|
+
});
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
#### Update Cart Item
|
|
216
|
+
|
|
217
|
+
```typescript
|
|
218
|
+
const cart = await omni.updateCartItem(cartId, itemId, {
|
|
219
|
+
quantity: 3,
|
|
220
|
+
});
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
#### Remove Cart Item
|
|
224
|
+
|
|
225
|
+
```typescript
|
|
226
|
+
const cart = await omni.removeCartItem(cartId, itemId);
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
#### Apply Coupon
|
|
230
|
+
|
|
231
|
+
```typescript
|
|
232
|
+
const cart = await omni.applyCoupon(cartId, 'SAVE20');
|
|
233
|
+
console.log(cart.discountAmount); // Discount applied
|
|
234
|
+
console.log(cart.couponCode); // 'SAVE20'
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
#### Remove Coupon
|
|
238
|
+
|
|
239
|
+
```typescript
|
|
240
|
+
const cart = await omni.removeCoupon(cartId);
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
#### Cart Type Definition
|
|
244
|
+
|
|
245
|
+
```typescript
|
|
246
|
+
interface Cart {
|
|
247
|
+
id: string;
|
|
248
|
+
sessionToken?: string | null;
|
|
249
|
+
customerId?: string | null;
|
|
250
|
+
status: 'ACTIVE' | 'MERGED' | 'CONVERTED' | 'ABANDONED';
|
|
251
|
+
currency: string;
|
|
252
|
+
subtotal: string;
|
|
253
|
+
discountAmount: string;
|
|
254
|
+
couponCode?: string | null;
|
|
255
|
+
items: CartItem[];
|
|
256
|
+
itemCount: number;
|
|
257
|
+
createdAt: string;
|
|
258
|
+
updatedAt: string;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
interface CartItem {
|
|
262
|
+
id: string;
|
|
263
|
+
productId: string;
|
|
264
|
+
variantId?: string | null;
|
|
265
|
+
quantity: number;
|
|
266
|
+
unitPrice: string;
|
|
267
|
+
discountAmount: string;
|
|
268
|
+
notes?: string | null;
|
|
269
|
+
product: {
|
|
270
|
+
id: string;
|
|
271
|
+
name: string;
|
|
272
|
+
sku: string;
|
|
273
|
+
images?: unknown[];
|
|
274
|
+
};
|
|
275
|
+
variant?: {
|
|
276
|
+
id: string;
|
|
277
|
+
name?: string | null;
|
|
278
|
+
sku?: string | null;
|
|
279
|
+
} | null;
|
|
280
|
+
}
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
---
|
|
284
|
+
|
|
285
|
+
### Checkout
|
|
286
|
+
|
|
287
|
+
#### Create Checkout from Cart
|
|
288
|
+
|
|
289
|
+
```typescript
|
|
290
|
+
const checkout = await omni.createCheckout({
|
|
291
|
+
cartId: cartId,
|
|
292
|
+
});
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
#### Set Customer Information
|
|
296
|
+
|
|
297
|
+
```typescript
|
|
298
|
+
const checkout = await omni.setCheckoutCustomer(checkoutId, {
|
|
299
|
+
email: 'customer@example.com',
|
|
300
|
+
firstName: 'John',
|
|
301
|
+
lastName: 'Doe',
|
|
302
|
+
phone: '+1234567890', // Optional
|
|
303
|
+
});
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
#### Set Shipping Address
|
|
307
|
+
|
|
308
|
+
```typescript
|
|
309
|
+
const { checkout, rates } = await omni.setShippingAddress(checkoutId, {
|
|
310
|
+
firstName: 'John',
|
|
311
|
+
lastName: 'Doe',
|
|
312
|
+
line1: '123 Main St',
|
|
313
|
+
line2: 'Apt 4B', // Optional
|
|
314
|
+
city: 'New York',
|
|
315
|
+
region: 'NY', // State/Province
|
|
316
|
+
postalCode: '10001',
|
|
317
|
+
country: 'US',
|
|
318
|
+
phone: '+1234567890', // Optional
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// rates contains available shipping options
|
|
322
|
+
console.log(rates); // ShippingRate[]
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
#### Select Shipping Method
|
|
326
|
+
|
|
327
|
+
```typescript
|
|
328
|
+
const checkout = await omni.selectShippingMethod(checkoutId, rates[0].id);
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
#### Set Billing Address
|
|
332
|
+
|
|
333
|
+
```typescript
|
|
334
|
+
// Same as shipping
|
|
335
|
+
const checkout = await omni.setBillingAddress(checkoutId, {
|
|
336
|
+
...shippingAddress,
|
|
337
|
+
sameAsShipping: true, // Optional shortcut
|
|
338
|
+
});
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
#### Complete Checkout
|
|
342
|
+
|
|
343
|
+
```typescript
|
|
344
|
+
const { orderId } = await omni.completeCheckout(checkoutId);
|
|
345
|
+
clearCartId(); // Clear cart from localStorage
|
|
346
|
+
console.log('Order created:', orderId);
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
#### Checkout Type Definition
|
|
350
|
+
|
|
351
|
+
```typescript
|
|
352
|
+
interface Checkout {
|
|
353
|
+
id: string;
|
|
354
|
+
status: CheckoutStatus;
|
|
355
|
+
email?: string | null;
|
|
356
|
+
shippingAddress?: CheckoutAddress | null;
|
|
357
|
+
billingAddress?: CheckoutAddress | null;
|
|
358
|
+
shippingMethod?: ShippingRate | null;
|
|
359
|
+
currency: string;
|
|
360
|
+
subtotal: string;
|
|
361
|
+
discountAmount: string;
|
|
362
|
+
shippingAmount: string;
|
|
363
|
+
taxAmount: string;
|
|
364
|
+
total: string;
|
|
365
|
+
couponCode?: string | null;
|
|
366
|
+
items: CheckoutLineItem[];
|
|
367
|
+
itemCount: number;
|
|
368
|
+
availableShippingRates?: ShippingRate[];
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
type CheckoutStatus =
|
|
372
|
+
| 'PENDING'
|
|
373
|
+
| 'SHIPPING_SET'
|
|
374
|
+
| 'PAYMENT_PENDING'
|
|
375
|
+
| 'COMPLETED'
|
|
376
|
+
| 'FAILED';
|
|
377
|
+
|
|
378
|
+
interface ShippingRate {
|
|
379
|
+
id: string;
|
|
380
|
+
name: string;
|
|
381
|
+
description?: string | null;
|
|
382
|
+
price: string;
|
|
383
|
+
currency: string;
|
|
384
|
+
estimatedDays?: number | null;
|
|
385
|
+
}
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
---
|
|
389
|
+
|
|
390
|
+
### Customer Authentication
|
|
391
|
+
|
|
392
|
+
#### Register Customer
|
|
393
|
+
|
|
394
|
+
```typescript
|
|
395
|
+
const auth = await omni.registerCustomer({
|
|
396
|
+
email: 'customer@example.com',
|
|
397
|
+
password: 'securepassword123',
|
|
398
|
+
firstName: 'John',
|
|
399
|
+
lastName: 'Doe',
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
setCustomerToken(auth.token);
|
|
403
|
+
console.log('Registered:', auth.customer.email);
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
#### Login Customer
|
|
407
|
+
|
|
408
|
+
```typescript
|
|
409
|
+
const auth = await omni.loginCustomer('customer@example.com', 'password123');
|
|
410
|
+
setCustomerToken(auth.token);
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
#### Logout Customer
|
|
414
|
+
|
|
415
|
+
```typescript
|
|
416
|
+
setCustomerToken(null);
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
#### Get Customer Profile
|
|
420
|
+
|
|
421
|
+
```typescript
|
|
422
|
+
restoreCustomerToken(); // Restore from localStorage
|
|
423
|
+
const profile = await omni.getMyProfile();
|
|
424
|
+
|
|
425
|
+
console.log(profile.firstName);
|
|
426
|
+
console.log(profile.email);
|
|
427
|
+
console.log(profile.addresses);
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
#### Get Customer Orders
|
|
431
|
+
|
|
432
|
+
```typescript
|
|
433
|
+
const { data: orders, meta } = await omni.getMyOrders({
|
|
434
|
+
page: 1,
|
|
435
|
+
limit: 10,
|
|
436
|
+
});
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
#### Auth Response Type
|
|
440
|
+
|
|
441
|
+
```typescript
|
|
442
|
+
interface CustomerAuthResponse {
|
|
443
|
+
customer: {
|
|
444
|
+
id: string;
|
|
445
|
+
email: string;
|
|
446
|
+
firstName?: string;
|
|
447
|
+
lastName?: string;
|
|
448
|
+
emailVerified: boolean;
|
|
449
|
+
};
|
|
450
|
+
token: string;
|
|
451
|
+
expiresAt: string;
|
|
452
|
+
}
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
---
|
|
456
|
+
|
|
457
|
+
### Customer Addresses
|
|
458
|
+
|
|
459
|
+
#### Get Addresses
|
|
460
|
+
|
|
461
|
+
```typescript
|
|
462
|
+
const addresses = await omni.getMyAddresses();
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
#### Add Address
|
|
466
|
+
|
|
467
|
+
```typescript
|
|
468
|
+
const address = await omni.addMyAddress({
|
|
469
|
+
firstName: 'John',
|
|
470
|
+
lastName: 'Doe',
|
|
471
|
+
line1: '123 Main St',
|
|
472
|
+
city: 'New York',
|
|
473
|
+
region: 'NY',
|
|
474
|
+
postalCode: '10001',
|
|
475
|
+
country: 'US',
|
|
476
|
+
isDefault: true,
|
|
477
|
+
});
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
#### Update Address
|
|
481
|
+
|
|
482
|
+
```typescript
|
|
483
|
+
const updated = await omni.updateMyAddress(addressId, {
|
|
484
|
+
line1: '456 New Street',
|
|
485
|
+
});
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
#### Delete Address
|
|
489
|
+
|
|
490
|
+
```typescript
|
|
491
|
+
await omni.deleteMyAddress(addressId);
|
|
492
|
+
```
|
|
493
|
+
|
|
494
|
+
---
|
|
495
|
+
|
|
496
|
+
### Store Info
|
|
497
|
+
|
|
498
|
+
```typescript
|
|
499
|
+
const store = await omni.getStoreInfo();
|
|
500
|
+
|
|
501
|
+
console.log(store.name); // Store name
|
|
502
|
+
console.log(store.currency); // 'USD', 'ILS', etc.
|
|
503
|
+
console.log(store.language); // 'en', 'he', etc.
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
---
|
|
507
|
+
|
|
508
|
+
## Complete Page Examples
|
|
509
|
+
|
|
510
|
+
### Home Page
|
|
511
|
+
|
|
512
|
+
```typescript
|
|
513
|
+
'use client';
|
|
514
|
+
import { useEffect, useState } from 'react';
|
|
515
|
+
import { omni } from '@/lib/omni-sync';
|
|
516
|
+
import type { Product } from 'omni-sync-sdk';
|
|
517
|
+
|
|
518
|
+
export default function HomePage() {
|
|
519
|
+
const [products, setProducts] = useState<Product[]>([]);
|
|
520
|
+
const [loading, setLoading] = useState(true);
|
|
521
|
+
const [error, setError] = useState<string | null>(null);
|
|
522
|
+
|
|
523
|
+
useEffect(() => {
|
|
524
|
+
async function loadProducts() {
|
|
525
|
+
try {
|
|
526
|
+
const { data } = await omni.getProducts({ limit: 8 });
|
|
527
|
+
setProducts(data);
|
|
528
|
+
} catch (err) {
|
|
529
|
+
setError('Failed to load products');
|
|
530
|
+
} finally {
|
|
531
|
+
setLoading(false);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
loadProducts();
|
|
535
|
+
}, []);
|
|
536
|
+
|
|
537
|
+
if (loading) return <div>Loading...</div>;
|
|
538
|
+
if (error) return <div>{error}</div>;
|
|
539
|
+
|
|
540
|
+
return (
|
|
541
|
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
|
542
|
+
{products.map((product) => (
|
|
543
|
+
<a key={product.id} href={`/products/${product.id}`} className="group">
|
|
544
|
+
<img
|
|
545
|
+
src={product.images?.[0]?.url || '/placeholder.jpg'}
|
|
546
|
+
alt={product.name}
|
|
547
|
+
className="w-full aspect-square object-cover"
|
|
548
|
+
/>
|
|
549
|
+
<h3 className="mt-2 font-medium">{product.name}</h3>
|
|
550
|
+
<p className="text-lg">
|
|
551
|
+
{product.salePrice ? (
|
|
552
|
+
<>
|
|
553
|
+
<span className="text-red-600">${product.salePrice}</span>
|
|
554
|
+
<span className="line-through text-gray-400 ml-2">${product.basePrice}</span>
|
|
555
|
+
</>
|
|
556
|
+
) : (
|
|
557
|
+
<span>${product.basePrice}</span>
|
|
558
|
+
)}
|
|
559
|
+
</p>
|
|
560
|
+
</a>
|
|
561
|
+
))}
|
|
562
|
+
</div>
|
|
563
|
+
);
|
|
564
|
+
}
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
### Products List with Pagination
|
|
568
|
+
|
|
569
|
+
```typescript
|
|
570
|
+
'use client';
|
|
571
|
+
import { useEffect, useState } from 'react';
|
|
572
|
+
import { omni } from '@/lib/omni-sync';
|
|
573
|
+
import type { Product, PaginatedResponse } from 'omni-sync-sdk';
|
|
574
|
+
|
|
575
|
+
export default function ProductsPage() {
|
|
576
|
+
const [data, setData] = useState<PaginatedResponse<Product> | null>(null);
|
|
577
|
+
const [page, setPage] = useState(1);
|
|
578
|
+
const [loading, setLoading] = useState(true);
|
|
579
|
+
|
|
580
|
+
useEffect(() => {
|
|
581
|
+
async function load() {
|
|
582
|
+
setLoading(true);
|
|
583
|
+
try {
|
|
584
|
+
const result = await omni.getProducts({ page, limit: 12 });
|
|
585
|
+
setData(result);
|
|
586
|
+
} finally {
|
|
587
|
+
setLoading(false);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
load();
|
|
591
|
+
}, [page]);
|
|
592
|
+
|
|
593
|
+
if (loading) return <div>Loading...</div>;
|
|
594
|
+
if (!data) return <div>No products found</div>;
|
|
595
|
+
|
|
596
|
+
return (
|
|
597
|
+
<div>
|
|
598
|
+
<div className="grid grid-cols-3 gap-6">
|
|
599
|
+
{data.data.map((product) => (
|
|
600
|
+
<a key={product.id} href={`/products/${product.id}`}>
|
|
601
|
+
<img src={product.images?.[0]?.url} alt={product.name} />
|
|
602
|
+
<h3>{product.name}</h3>
|
|
603
|
+
<p>${product.salePrice || product.basePrice}</p>
|
|
604
|
+
</a>
|
|
605
|
+
))}
|
|
606
|
+
</div>
|
|
607
|
+
|
|
608
|
+
{/* Pagination */}
|
|
609
|
+
<div className="flex gap-2 mt-8">
|
|
610
|
+
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1}>
|
|
611
|
+
Previous
|
|
612
|
+
</button>
|
|
613
|
+
<span>Page {data.meta.page} of {data.meta.totalPages}</span>
|
|
614
|
+
<button onClick={() => setPage(p => p + 1)} disabled={page >= data.meta.totalPages}>
|
|
615
|
+
Next
|
|
616
|
+
</button>
|
|
617
|
+
</div>
|
|
618
|
+
</div>
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
### Product Detail with Add to Cart
|
|
624
|
+
|
|
625
|
+
```typescript
|
|
626
|
+
'use client';
|
|
627
|
+
import { useEffect, useState } from 'react';
|
|
628
|
+
import { omni, getCartId, setCartId } from '@/lib/omni-sync';
|
|
629
|
+
import type { Product } from 'omni-sync-sdk';
|
|
630
|
+
|
|
631
|
+
export default function ProductPage({ params }: { params: { id: string } }) {
|
|
632
|
+
const [product, setProduct] = useState<Product | null>(null);
|
|
633
|
+
const [selectedVariant, setSelectedVariant] = useState<string | null>(null);
|
|
634
|
+
const [quantity, setQuantity] = useState(1);
|
|
635
|
+
const [loading, setLoading] = useState(true);
|
|
636
|
+
const [adding, setAdding] = useState(false);
|
|
637
|
+
|
|
638
|
+
useEffect(() => {
|
|
639
|
+
async function load() {
|
|
640
|
+
try {
|
|
641
|
+
const p = await omni.getProduct(params.id);
|
|
642
|
+
setProduct(p);
|
|
643
|
+
if (p.variants && p.variants.length > 0) {
|
|
644
|
+
setSelectedVariant(p.variants[0].id);
|
|
645
|
+
}
|
|
646
|
+
} finally {
|
|
647
|
+
setLoading(false);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
load();
|
|
651
|
+
}, [params.id]);
|
|
652
|
+
|
|
653
|
+
const handleAddToCart = async () => {
|
|
654
|
+
if (!product) return;
|
|
655
|
+
setAdding(true);
|
|
656
|
+
try {
|
|
657
|
+
let cartId = getCartId();
|
|
658
|
+
|
|
659
|
+
if (!cartId) {
|
|
660
|
+
const cart = await omni.createCart();
|
|
661
|
+
cartId = cart.id;
|
|
662
|
+
setCartId(cartId);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
await omni.addToCart(cartId, {
|
|
666
|
+
productId: product.id,
|
|
667
|
+
variantId: selectedVariant || undefined,
|
|
668
|
+
quantity,
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
alert('Added to cart!');
|
|
672
|
+
} catch (err) {
|
|
673
|
+
alert('Failed to add to cart');
|
|
674
|
+
} finally {
|
|
675
|
+
setAdding(false);
|
|
676
|
+
}
|
|
677
|
+
};
|
|
678
|
+
|
|
679
|
+
if (loading) return <div>Loading...</div>;
|
|
680
|
+
if (!product) return <div>Product not found</div>;
|
|
681
|
+
|
|
682
|
+
return (
|
|
683
|
+
<div className="grid grid-cols-2 gap-8">
|
|
684
|
+
{/* Images */}
|
|
685
|
+
<div>
|
|
686
|
+
<img
|
|
687
|
+
src={product.images?.[0]?.url || '/placeholder.jpg'}
|
|
688
|
+
alt={product.name}
|
|
689
|
+
className="w-full"
|
|
690
|
+
/>
|
|
691
|
+
</div>
|
|
692
|
+
|
|
693
|
+
{/* Details */}
|
|
694
|
+
<div>
|
|
695
|
+
<h1 className="text-3xl font-bold">{product.name}</h1>
|
|
696
|
+
<p className="text-2xl mt-4">
|
|
697
|
+
${product.salePrice || product.basePrice}
|
|
698
|
+
</p>
|
|
699
|
+
|
|
700
|
+
{product.description && (
|
|
701
|
+
<p className="mt-4 text-gray-600">{product.description}</p>
|
|
702
|
+
)}
|
|
703
|
+
|
|
704
|
+
{/* Variant Selection */}
|
|
705
|
+
{product.variants && product.variants.length > 0 && (
|
|
706
|
+
<div className="mt-6">
|
|
707
|
+
<label className="block font-medium mb-2">Select Option</label>
|
|
708
|
+
<select
|
|
709
|
+
value={selectedVariant || ''}
|
|
710
|
+
onChange={(e) => setSelectedVariant(e.target.value)}
|
|
711
|
+
className="border rounded p-2 w-full"
|
|
712
|
+
>
|
|
713
|
+
{product.variants.map((v) => (
|
|
714
|
+
<option key={v.id} value={v.id}>
|
|
715
|
+
{v.name || v.sku} - ${v.price || product.basePrice}
|
|
716
|
+
</option>
|
|
717
|
+
))}
|
|
718
|
+
</select>
|
|
719
|
+
</div>
|
|
720
|
+
)}
|
|
721
|
+
|
|
722
|
+
{/* Quantity */}
|
|
723
|
+
<div className="mt-4">
|
|
724
|
+
<label className="block font-medium mb-2">Quantity</label>
|
|
725
|
+
<input
|
|
726
|
+
type="number"
|
|
727
|
+
min="1"
|
|
728
|
+
value={quantity}
|
|
729
|
+
onChange={(e) => setQuantity(Number(e.target.value))}
|
|
730
|
+
className="border rounded p-2 w-20"
|
|
731
|
+
/>
|
|
732
|
+
</div>
|
|
733
|
+
|
|
734
|
+
{/* Add to Cart Button */}
|
|
735
|
+
<button
|
|
736
|
+
onClick={handleAddToCart}
|
|
737
|
+
disabled={adding}
|
|
738
|
+
className="mt-6 w-full bg-black text-white py-3 rounded disabled:opacity-50"
|
|
739
|
+
>
|
|
740
|
+
{adding ? 'Adding...' : 'Add to Cart'}
|
|
741
|
+
</button>
|
|
742
|
+
|
|
743
|
+
{/* Stock Status */}
|
|
744
|
+
{product.inventory && (
|
|
745
|
+
<p className="mt-4 text-sm">
|
|
746
|
+
{product.inventory.available > 0
|
|
747
|
+
? `${product.inventory.available} in stock`
|
|
748
|
+
: 'Out of stock'}
|
|
749
|
+
</p>
|
|
750
|
+
)}
|
|
751
|
+
</div>
|
|
752
|
+
</div>
|
|
753
|
+
);
|
|
754
|
+
}
|
|
755
|
+
```
|
|
756
|
+
|
|
757
|
+
### Cart Page
|
|
758
|
+
|
|
759
|
+
```typescript
|
|
760
|
+
'use client';
|
|
761
|
+
import { useEffect, useState } from 'react';
|
|
762
|
+
import { omni, getCartId } from '@/lib/omni-sync';
|
|
763
|
+
import type { Cart } from 'omni-sync-sdk';
|
|
764
|
+
|
|
765
|
+
export default function CartPage() {
|
|
766
|
+
const [cart, setCart] = useState<Cart | null>(null);
|
|
767
|
+
const [loading, setLoading] = useState(true);
|
|
768
|
+
const [updating, setUpdating] = useState<string | null>(null);
|
|
769
|
+
|
|
770
|
+
const loadCart = async () => {
|
|
771
|
+
const cartId = getCartId();
|
|
772
|
+
if (!cartId) {
|
|
773
|
+
setLoading(false);
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
try {
|
|
777
|
+
const c = await omni.getCart(cartId);
|
|
778
|
+
setCart(c);
|
|
779
|
+
} finally {
|
|
780
|
+
setLoading(false);
|
|
781
|
+
}
|
|
782
|
+
};
|
|
783
|
+
|
|
784
|
+
useEffect(() => { loadCart(); }, []);
|
|
785
|
+
|
|
786
|
+
const updateQuantity = async (itemId: string, quantity: number) => {
|
|
787
|
+
if (!cart) return;
|
|
788
|
+
setUpdating(itemId);
|
|
789
|
+
try {
|
|
790
|
+
if (quantity <= 0) {
|
|
791
|
+
await omni.removeCartItem(cart.id, itemId);
|
|
792
|
+
} else {
|
|
793
|
+
await omni.updateCartItem(cart.id, itemId, { quantity });
|
|
794
|
+
}
|
|
795
|
+
await loadCart();
|
|
796
|
+
} finally {
|
|
797
|
+
setUpdating(null);
|
|
798
|
+
}
|
|
799
|
+
};
|
|
800
|
+
|
|
801
|
+
const removeItem = async (itemId: string) => {
|
|
802
|
+
if (!cart) return;
|
|
803
|
+
setUpdating(itemId);
|
|
804
|
+
try {
|
|
805
|
+
await omni.removeCartItem(cart.id, itemId);
|
|
806
|
+
await loadCart();
|
|
807
|
+
} finally {
|
|
808
|
+
setUpdating(null);
|
|
809
|
+
}
|
|
810
|
+
};
|
|
811
|
+
|
|
812
|
+
if (loading) return <div>Loading cart...</div>;
|
|
813
|
+
if (!cart || cart.items.length === 0) {
|
|
814
|
+
return (
|
|
815
|
+
<div className="text-center py-12">
|
|
816
|
+
<h1 className="text-2xl font-bold">Your cart is empty</h1>
|
|
817
|
+
<a href="/products" className="text-blue-600 mt-4 inline-block">Continue Shopping</a>
|
|
818
|
+
</div>
|
|
819
|
+
);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
return (
|
|
823
|
+
<div>
|
|
824
|
+
<h1 className="text-2xl font-bold mb-6">Shopping Cart</h1>
|
|
825
|
+
|
|
826
|
+
{cart.items.map((item) => (
|
|
827
|
+
<div key={item.id} className="flex items-center gap-4 py-4 border-b">
|
|
828
|
+
<img
|
|
829
|
+
src={item.product.images?.[0]?.url || '/placeholder.jpg'}
|
|
830
|
+
alt={item.product.name}
|
|
831
|
+
className="w-20 h-20 object-cover"
|
|
832
|
+
/>
|
|
833
|
+
<div className="flex-1">
|
|
834
|
+
<h3 className="font-medium">{item.product.name}</h3>
|
|
835
|
+
{item.variant && <p className="text-sm text-gray-500">{item.variant.name}</p>}
|
|
836
|
+
<p className="font-bold">${item.unitPrice}</p>
|
|
837
|
+
</div>
|
|
838
|
+
<div className="flex items-center gap-2">
|
|
839
|
+
<button
|
|
840
|
+
onClick={() => updateQuantity(item.id, item.quantity - 1)}
|
|
841
|
+
disabled={updating === item.id}
|
|
842
|
+
className="w-8 h-8 border rounded"
|
|
843
|
+
>-</button>
|
|
844
|
+
<span className="w-8 text-center">{item.quantity}</span>
|
|
845
|
+
<button
|
|
846
|
+
onClick={() => updateQuantity(item.id, item.quantity + 1)}
|
|
847
|
+
disabled={updating === item.id}
|
|
848
|
+
className="w-8 h-8 border rounded"
|
|
849
|
+
>+</button>
|
|
850
|
+
</div>
|
|
851
|
+
<button
|
|
852
|
+
onClick={() => removeItem(item.id)}
|
|
853
|
+
disabled={updating === item.id}
|
|
854
|
+
className="text-red-600"
|
|
855
|
+
>Remove</button>
|
|
856
|
+
</div>
|
|
857
|
+
))}
|
|
858
|
+
|
|
859
|
+
<div className="mt-6 text-right">
|
|
860
|
+
<p className="text-xl">Subtotal: <strong>${cart.subtotal}</strong></p>
|
|
861
|
+
{cart.discountAmount && Number(cart.discountAmount) > 0 && (
|
|
862
|
+
<p className="text-green-600">Discount: -${cart.discountAmount}</p>
|
|
863
|
+
)}
|
|
864
|
+
<a
|
|
865
|
+
href="/checkout"
|
|
866
|
+
className="mt-4 inline-block bg-black text-white px-8 py-3 rounded"
|
|
867
|
+
>
|
|
868
|
+
Proceed to Checkout
|
|
869
|
+
</a>
|
|
870
|
+
</div>
|
|
871
|
+
</div>
|
|
872
|
+
);
|
|
873
|
+
}
|
|
874
|
+
```
|
|
875
|
+
|
|
876
|
+
### Multi-Step Checkout
|
|
877
|
+
|
|
878
|
+
```typescript
|
|
879
|
+
'use client';
|
|
880
|
+
import { useEffect, useState } from 'react';
|
|
881
|
+
import { omni, getCartId, clearCartId } from '@/lib/omni-sync';
|
|
882
|
+
import type { Checkout, ShippingRate } from 'omni-sync-sdk';
|
|
883
|
+
|
|
884
|
+
type Step = 'customer' | 'shipping' | 'payment' | 'complete';
|
|
885
|
+
|
|
886
|
+
export default function CheckoutPage() {
|
|
887
|
+
const [checkout, setCheckout] = useState<Checkout | null>(null);
|
|
888
|
+
const [step, setStep] = useState<Step>('customer');
|
|
889
|
+
const [shippingRates, setShippingRates] = useState<ShippingRate[]>([]);
|
|
890
|
+
const [loading, setLoading] = useState(true);
|
|
891
|
+
const [submitting, setSubmitting] = useState(false);
|
|
892
|
+
|
|
893
|
+
// Form state
|
|
894
|
+
const [email, setEmail] = useState('');
|
|
895
|
+
const [firstName, setFirstName] = useState('');
|
|
896
|
+
const [lastName, setLastName] = useState('');
|
|
897
|
+
const [address, setAddress] = useState('');
|
|
898
|
+
const [city, setCity] = useState('');
|
|
899
|
+
const [postalCode, setPostalCode] = useState('');
|
|
900
|
+
const [country, setCountry] = useState('US');
|
|
901
|
+
|
|
902
|
+
useEffect(() => {
|
|
903
|
+
async function initCheckout() {
|
|
904
|
+
const cartId = getCartId();
|
|
905
|
+
if (!cartId) {
|
|
906
|
+
window.location.href = '/cart';
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
try {
|
|
910
|
+
const c = await omni.createCheckout({ cartId });
|
|
911
|
+
setCheckout(c);
|
|
912
|
+
} finally {
|
|
913
|
+
setLoading(false);
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
initCheckout();
|
|
917
|
+
}, []);
|
|
918
|
+
|
|
919
|
+
const handleCustomerSubmit = async (e: React.FormEvent) => {
|
|
920
|
+
e.preventDefault();
|
|
921
|
+
if (!checkout) return;
|
|
922
|
+
setSubmitting(true);
|
|
923
|
+
try {
|
|
924
|
+
await omni.setCheckoutCustomer(checkout.id, { email, firstName, lastName });
|
|
925
|
+
setStep('shipping');
|
|
926
|
+
} finally {
|
|
927
|
+
setSubmitting(false);
|
|
928
|
+
}
|
|
929
|
+
};
|
|
930
|
+
|
|
931
|
+
const handleShippingSubmit = async (e: React.FormEvent) => {
|
|
932
|
+
e.preventDefault();
|
|
933
|
+
if (!checkout) return;
|
|
934
|
+
setSubmitting(true);
|
|
935
|
+
try {
|
|
936
|
+
const { rates } = await omni.setShippingAddress(checkout.id, {
|
|
937
|
+
firstName, lastName,
|
|
938
|
+
line1: address,
|
|
939
|
+
city, postalCode, country,
|
|
940
|
+
});
|
|
941
|
+
setShippingRates(rates);
|
|
942
|
+
if (rates.length > 0) {
|
|
943
|
+
await omni.selectShippingMethod(checkout.id, rates[0].id);
|
|
944
|
+
}
|
|
945
|
+
setStep('payment');
|
|
946
|
+
} finally {
|
|
947
|
+
setSubmitting(false);
|
|
948
|
+
}
|
|
949
|
+
};
|
|
950
|
+
|
|
951
|
+
const handleCompleteOrder = async () => {
|
|
952
|
+
if (!checkout) return;
|
|
953
|
+
setSubmitting(true);
|
|
954
|
+
try {
|
|
955
|
+
const { orderId } = await omni.completeCheckout(checkout.id);
|
|
956
|
+
clearCartId();
|
|
957
|
+
setStep('complete');
|
|
958
|
+
} catch (err) {
|
|
959
|
+
alert('Failed to complete order');
|
|
960
|
+
} finally {
|
|
961
|
+
setSubmitting(false);
|
|
962
|
+
}
|
|
963
|
+
};
|
|
964
|
+
|
|
965
|
+
if (loading) return <div>Loading checkout...</div>;
|
|
966
|
+
if (!checkout) return <div>Failed to create checkout</div>;
|
|
967
|
+
|
|
968
|
+
if (step === 'complete') {
|
|
969
|
+
return (
|
|
970
|
+
<div className="text-center py-12">
|
|
971
|
+
<h1 className="text-3xl font-bold text-green-600">Order Complete!</h1>
|
|
972
|
+
<p className="mt-4">Thank you for your purchase.</p>
|
|
973
|
+
<a href="/" className="mt-6 inline-block text-blue-600">Continue Shopping</a>
|
|
974
|
+
</div>
|
|
975
|
+
);
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
return (
|
|
979
|
+
<div className="max-w-2xl mx-auto">
|
|
980
|
+
<h1 className="text-2xl font-bold mb-6">Checkout</h1>
|
|
981
|
+
|
|
982
|
+
{step === 'customer' && (
|
|
983
|
+
<form onSubmit={handleCustomerSubmit} className="space-y-4">
|
|
984
|
+
<input type="email" placeholder="Email" value={email} onChange={e => setEmail(e.target.value)} required className="w-full border p-2 rounded" />
|
|
985
|
+
<div className="grid grid-cols-2 gap-4">
|
|
986
|
+
<input placeholder="First Name" value={firstName} onChange={e => setFirstName(e.target.value)} required className="border p-2 rounded" />
|
|
987
|
+
<input placeholder="Last Name" value={lastName} onChange={e => setLastName(e.target.value)} required className="border p-2 rounded" />
|
|
988
|
+
</div>
|
|
989
|
+
<button type="submit" disabled={submitting} className="w-full bg-black text-white py-3 rounded">
|
|
990
|
+
{submitting ? 'Saving...' : 'Continue to Shipping'}
|
|
991
|
+
</button>
|
|
992
|
+
</form>
|
|
993
|
+
)}
|
|
994
|
+
|
|
995
|
+
{step === 'shipping' && (
|
|
996
|
+
<form onSubmit={handleShippingSubmit} className="space-y-4">
|
|
997
|
+
<input placeholder="Address" value={address} onChange={e => setAddress(e.target.value)} required className="w-full border p-2 rounded" />
|
|
998
|
+
<div className="grid grid-cols-2 gap-4">
|
|
999
|
+
<input placeholder="City" value={city} onChange={e => setCity(e.target.value)} required className="border p-2 rounded" />
|
|
1000
|
+
<input placeholder="Postal Code" value={postalCode} onChange={e => setPostalCode(e.target.value)} required className="border p-2 rounded" />
|
|
1001
|
+
</div>
|
|
1002
|
+
<select value={country} onChange={e => setCountry(e.target.value)} className="w-full border p-2 rounded">
|
|
1003
|
+
<option value="US">United States</option>
|
|
1004
|
+
<option value="IL">Israel</option>
|
|
1005
|
+
<option value="GB">United Kingdom</option>
|
|
1006
|
+
</select>
|
|
1007
|
+
<button type="submit" disabled={submitting} className="w-full bg-black text-white py-3 rounded">
|
|
1008
|
+
{submitting ? 'Calculating Shipping...' : 'Continue to Payment'}
|
|
1009
|
+
</button>
|
|
1010
|
+
</form>
|
|
1011
|
+
)}
|
|
1012
|
+
|
|
1013
|
+
{step === 'payment' && (
|
|
1014
|
+
<div className="space-y-6">
|
|
1015
|
+
<div className="border p-4 rounded">
|
|
1016
|
+
<h3 className="font-bold mb-2">Order Summary</h3>
|
|
1017
|
+
<p>Subtotal: ${checkout.subtotal}</p>
|
|
1018
|
+
<p>Shipping: ${checkout.shippingAmount}</p>
|
|
1019
|
+
<p className="text-xl font-bold mt-2">Total: ${checkout.total}</p>
|
|
1020
|
+
</div>
|
|
1021
|
+
<button onClick={handleCompleteOrder} disabled={submitting} className="w-full bg-green-600 text-white py-3 rounded text-lg">
|
|
1022
|
+
{submitting ? 'Processing...' : 'Complete Order'}
|
|
1023
|
+
</button>
|
|
1024
|
+
</div>
|
|
1025
|
+
)}
|
|
1026
|
+
</div>
|
|
1027
|
+
);
|
|
1028
|
+
}
|
|
1029
|
+
```
|
|
1030
|
+
|
|
1031
|
+
### Login Page
|
|
1032
|
+
|
|
1033
|
+
```typescript
|
|
1034
|
+
'use client';
|
|
1035
|
+
import { useState } from 'react';
|
|
1036
|
+
import { omni, setCustomerToken } from '@/lib/omni-sync';
|
|
1037
|
+
|
|
1038
|
+
export default function LoginPage() {
|
|
1039
|
+
const [email, setEmail] = useState('');
|
|
1040
|
+
const [password, setPassword] = useState('');
|
|
1041
|
+
const [error, setError] = useState('');
|
|
1042
|
+
const [loading, setLoading] = useState(false);
|
|
1043
|
+
|
|
1044
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
1045
|
+
e.preventDefault();
|
|
1046
|
+
setLoading(true);
|
|
1047
|
+
setError('');
|
|
1048
|
+
try {
|
|
1049
|
+
const auth = await omni.loginCustomer(email, password);
|
|
1050
|
+
setCustomerToken(auth.token);
|
|
1051
|
+
window.location.href = '/account';
|
|
1052
|
+
} catch (err) {
|
|
1053
|
+
setError('Invalid email or password');
|
|
1054
|
+
} finally {
|
|
1055
|
+
setLoading(false);
|
|
1056
|
+
}
|
|
1057
|
+
};
|
|
1058
|
+
|
|
1059
|
+
return (
|
|
1060
|
+
<div className="max-w-md mx-auto mt-12">
|
|
1061
|
+
<h1 className="text-2xl font-bold mb-6">Login</h1>
|
|
1062
|
+
{error && <div className="bg-red-100 text-red-600 p-3 rounded mb-4">{error}</div>}
|
|
1063
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
1064
|
+
<input type="email" placeholder="Email" value={email} onChange={e => setEmail(e.target.value)} required className="w-full border p-2 rounded" />
|
|
1065
|
+
<input type="password" placeholder="Password" value={password} onChange={e => setPassword(e.target.value)} required className="w-full border p-2 rounded" />
|
|
1066
|
+
<button type="submit" disabled={loading} className="w-full bg-black text-white py-3 rounded">
|
|
1067
|
+
{loading ? 'Logging in...' : 'Login'}
|
|
1068
|
+
</button>
|
|
1069
|
+
</form>
|
|
1070
|
+
<p className="mt-4 text-center">
|
|
1071
|
+
Don't have an account? <a href="/register" className="text-blue-600">Register</a>
|
|
1072
|
+
</p>
|
|
1073
|
+
</div>
|
|
1074
|
+
);
|
|
1075
|
+
}
|
|
1076
|
+
```
|
|
1077
|
+
|
|
1078
|
+
### Register Page
|
|
1079
|
+
|
|
1080
|
+
```typescript
|
|
1081
|
+
'use client';
|
|
1082
|
+
import { useState } from 'react';
|
|
1083
|
+
import { omni, setCustomerToken } from '@/lib/omni-sync';
|
|
1084
|
+
|
|
1085
|
+
export default function RegisterPage() {
|
|
1086
|
+
const [email, setEmail] = useState('');
|
|
1087
|
+
const [password, setPassword] = useState('');
|
|
1088
|
+
const [firstName, setFirstName] = useState('');
|
|
1089
|
+
const [lastName, setLastName] = useState('');
|
|
1090
|
+
const [error, setError] = useState('');
|
|
1091
|
+
const [loading, setLoading] = useState(false);
|
|
1092
|
+
|
|
1093
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
1094
|
+
e.preventDefault();
|
|
1095
|
+
setLoading(true);
|
|
1096
|
+
setError('');
|
|
1097
|
+
try {
|
|
1098
|
+
const auth = await omni.registerCustomer({ email, password, firstName, lastName });
|
|
1099
|
+
setCustomerToken(auth.token);
|
|
1100
|
+
window.location.href = '/account';
|
|
1101
|
+
} catch (err) {
|
|
1102
|
+
setError('Registration failed. Email may already be in use.');
|
|
1103
|
+
} finally {
|
|
1104
|
+
setLoading(false);
|
|
1105
|
+
}
|
|
1106
|
+
};
|
|
1107
|
+
|
|
1108
|
+
return (
|
|
1109
|
+
<div className="max-w-md mx-auto mt-12">
|
|
1110
|
+
<h1 className="text-2xl font-bold mb-6">Create Account</h1>
|
|
1111
|
+
{error && <div className="bg-red-100 text-red-600 p-3 rounded mb-4">{error}</div>}
|
|
1112
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
1113
|
+
<div className="grid grid-cols-2 gap-4">
|
|
1114
|
+
<input placeholder="First Name" value={firstName} onChange={e => setFirstName(e.target.value)} required className="border p-2 rounded" />
|
|
1115
|
+
<input placeholder="Last Name" value={lastName} onChange={e => setLastName(e.target.value)} required className="border p-2 rounded" />
|
|
1116
|
+
</div>
|
|
1117
|
+
<input type="email" placeholder="Email" value={email} onChange={e => setEmail(e.target.value)} required className="w-full border p-2 rounded" />
|
|
1118
|
+
<input type="password" placeholder="Password (min 8 characters)" value={password} onChange={e => setPassword(e.target.value)} required minLength={8} className="w-full border p-2 rounded" />
|
|
1119
|
+
<button type="submit" disabled={loading} className="w-full bg-black text-white py-3 rounded">
|
|
1120
|
+
{loading ? 'Creating Account...' : 'Create Account'}
|
|
1121
|
+
</button>
|
|
1122
|
+
</form>
|
|
1123
|
+
<p className="mt-4 text-center">
|
|
1124
|
+
Already have an account? <a href="/login" className="text-blue-600">Login</a>
|
|
1125
|
+
</p>
|
|
1126
|
+
</div>
|
|
1127
|
+
);
|
|
1128
|
+
}
|
|
1129
|
+
```
|
|
1130
|
+
|
|
1131
|
+
### Account Page
|
|
1132
|
+
|
|
1133
|
+
```typescript
|
|
1134
|
+
'use client';
|
|
1135
|
+
import { useEffect, useState } from 'react';
|
|
1136
|
+
import { omni, restoreCustomerToken, setCustomerToken, isLoggedIn } from '@/lib/omni-sync';
|
|
1137
|
+
import type { CustomerProfile, Order } from 'omni-sync-sdk';
|
|
1138
|
+
|
|
1139
|
+
export default function AccountPage() {
|
|
1140
|
+
const [profile, setProfile] = useState<CustomerProfile | null>(null);
|
|
1141
|
+
const [orders, setOrders] = useState<Order[]>([]);
|
|
1142
|
+
const [loading, setLoading] = useState(true);
|
|
1143
|
+
|
|
1144
|
+
useEffect(() => {
|
|
1145
|
+
restoreCustomerToken();
|
|
1146
|
+
if (!isLoggedIn()) {
|
|
1147
|
+
window.location.href = '/login';
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
async function load() {
|
|
1152
|
+
try {
|
|
1153
|
+
const [p, o] = await Promise.all([
|
|
1154
|
+
omni.getMyProfile(),
|
|
1155
|
+
omni.getMyOrders({ limit: 10 }),
|
|
1156
|
+
]);
|
|
1157
|
+
setProfile(p);
|
|
1158
|
+
setOrders(o.data);
|
|
1159
|
+
} finally {
|
|
1160
|
+
setLoading(false);
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
load();
|
|
1164
|
+
}, []);
|
|
1165
|
+
|
|
1166
|
+
const handleLogout = () => {
|
|
1167
|
+
setCustomerToken(null);
|
|
1168
|
+
window.location.href = '/';
|
|
1169
|
+
};
|
|
1170
|
+
|
|
1171
|
+
if (loading) return <div>Loading...</div>;
|
|
1172
|
+
if (!profile) return <div>Please log in</div>;
|
|
1173
|
+
|
|
1174
|
+
return (
|
|
1175
|
+
<div>
|
|
1176
|
+
<div className="flex justify-between items-center mb-8">
|
|
1177
|
+
<h1 className="text-2xl font-bold">My Account</h1>
|
|
1178
|
+
<button onClick={handleLogout} className="text-red-600">Logout</button>
|
|
1179
|
+
</div>
|
|
1180
|
+
|
|
1181
|
+
<div className="grid md:grid-cols-2 gap-8">
|
|
1182
|
+
<div className="border rounded p-6">
|
|
1183
|
+
<h2 className="text-xl font-bold mb-4">Profile</h2>
|
|
1184
|
+
<p><strong>Name:</strong> {profile.firstName} {profile.lastName}</p>
|
|
1185
|
+
<p><strong>Email:</strong> {profile.email}</p>
|
|
1186
|
+
</div>
|
|
1187
|
+
|
|
1188
|
+
<div className="border rounded p-6">
|
|
1189
|
+
<h2 className="text-xl font-bold mb-4">Recent Orders</h2>
|
|
1190
|
+
{orders.length === 0 ? (
|
|
1191
|
+
<p className="text-gray-500">No orders yet</p>
|
|
1192
|
+
) : (
|
|
1193
|
+
<div className="space-y-4">
|
|
1194
|
+
{orders.map((order) => (
|
|
1195
|
+
<div key={order.id} className="border-b pb-4">
|
|
1196
|
+
<span className="font-medium">#{order.id.slice(-8)}</span>
|
|
1197
|
+
<span className="ml-2 text-sm">{order.status}</span>
|
|
1198
|
+
<p className="font-bold">${order.totalAmount}</p>
|
|
1199
|
+
</div>
|
|
1200
|
+
))}
|
|
1201
|
+
</div>
|
|
1202
|
+
)}
|
|
1203
|
+
</div>
|
|
1204
|
+
</div>
|
|
1205
|
+
</div>
|
|
1206
|
+
);
|
|
1207
|
+
}
|
|
1208
|
+
```
|
|
1209
|
+
|
|
1210
|
+
### Header Component with Cart Count
|
|
1211
|
+
|
|
1212
|
+
```typescript
|
|
1213
|
+
'use client';
|
|
1214
|
+
import { useEffect, useState } from 'react';
|
|
1215
|
+
import { omni, getCartId, isLoggedIn } from '@/lib/omni-sync';
|
|
1216
|
+
|
|
1217
|
+
export function Header() {
|
|
1218
|
+
const [cartCount, setCartCount] = useState(0);
|
|
1219
|
+
const [loggedIn, setLoggedIn] = useState(false);
|
|
1220
|
+
|
|
1221
|
+
useEffect(() => {
|
|
1222
|
+
setLoggedIn(isLoggedIn());
|
|
1223
|
+
|
|
1224
|
+
async function loadCart() {
|
|
1225
|
+
const cartId = getCartId();
|
|
1226
|
+
if (cartId) {
|
|
1227
|
+
try {
|
|
1228
|
+
const cart = await omni.getCart(cartId);
|
|
1229
|
+
setCartCount(cart.itemCount);
|
|
1230
|
+
} catch {}
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
loadCart();
|
|
1234
|
+
}, []);
|
|
1235
|
+
|
|
1236
|
+
return (
|
|
1237
|
+
<header className="flex justify-between items-center p-4 border-b">
|
|
1238
|
+
<a href="/" className="text-xl font-bold">Store Name</a>
|
|
1239
|
+
<nav className="flex gap-6 items-center">
|
|
1240
|
+
<a href="/products">Shop</a>
|
|
1241
|
+
<a href="/cart" className="relative">
|
|
1242
|
+
Cart
|
|
1243
|
+
{cartCount > 0 && (
|
|
1244
|
+
<span className="absolute -top-2 -right-2 bg-red-600 text-white text-xs w-5 h-5 rounded-full flex items-center justify-center">
|
|
1245
|
+
{cartCount}
|
|
1246
|
+
</span>
|
|
1247
|
+
)}
|
|
1248
|
+
</a>
|
|
1249
|
+
{loggedIn ? (
|
|
1250
|
+
<a href="/account">Account</a>
|
|
1251
|
+
) : (
|
|
1252
|
+
<a href="/login">Login</a>
|
|
1253
|
+
)}
|
|
1254
|
+
</nav>
|
|
1255
|
+
</header>
|
|
1256
|
+
);
|
|
1257
|
+
}
|
|
1258
|
+
```
|
|
1259
|
+
|
|
1260
|
+
---
|
|
1261
|
+
|
|
1262
|
+
## Error Handling
|
|
1263
|
+
|
|
1264
|
+
```typescript
|
|
1265
|
+
import { OmniSyncClient, OmniSyncError } from 'omni-sync-sdk';
|
|
1266
|
+
|
|
1267
|
+
try {
|
|
1268
|
+
const product = await omni.getProduct('invalid_id');
|
|
1269
|
+
} catch (error) {
|
|
1270
|
+
if (error instanceof OmniSyncError) {
|
|
1271
|
+
console.error(`API Error: ${error.message}`);
|
|
1272
|
+
console.error(`Status Code: ${error.statusCode}`);
|
|
1273
|
+
console.error(`Details:`, error.details);
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
```
|
|
1277
|
+
|
|
1278
|
+
---
|
|
1279
|
+
|
|
1280
|
+
## Webhooks
|
|
1281
|
+
|
|
1282
|
+
Receive real-time updates when products, orders, or inventory change.
|
|
1283
|
+
|
|
1284
|
+
### Setup Webhook Endpoint
|
|
1285
|
+
|
|
1286
|
+
```typescript
|
|
1287
|
+
// api/webhooks/omni-sync/route.ts (Next.js App Router)
|
|
1288
|
+
import { verifyWebhook, createWebhookHandler } from 'omni-sync-sdk';
|
|
1289
|
+
|
|
1290
|
+
const handler = createWebhookHandler({
|
|
1291
|
+
'product.updated': async (event) => {
|
|
1292
|
+
console.log('Product updated:', event.entityId);
|
|
1293
|
+
// Invalidate cache, update UI, etc.
|
|
1294
|
+
},
|
|
1295
|
+
'inventory.updated': async (event) => {
|
|
1296
|
+
console.log('Stock changed:', event.data);
|
|
1297
|
+
},
|
|
1298
|
+
'order.created': async (event) => {
|
|
1299
|
+
console.log('New order from:', event.platform);
|
|
1300
|
+
},
|
|
1301
|
+
});
|
|
1302
|
+
|
|
1303
|
+
export async function POST(req: Request) {
|
|
1304
|
+
const signature = req.headers.get('x-omni-signature');
|
|
1305
|
+
const body = await req.json();
|
|
1306
|
+
|
|
1307
|
+
// Verify signature
|
|
1308
|
+
if (!verifyWebhook(body, signature, process.env.OMNI_SYNC_WEBHOOK_SECRET!)) {
|
|
1309
|
+
return new Response('Invalid signature', { status: 401 });
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
// Process event
|
|
1313
|
+
await handler(body);
|
|
1314
|
+
|
|
1315
|
+
return new Response('OK');
|
|
1316
|
+
}
|
|
1317
|
+
```
|
|
1318
|
+
|
|
1319
|
+
### Webhook Events
|
|
1320
|
+
|
|
1321
|
+
| Event | Description |
|
|
1322
|
+
|-------|-------------|
|
|
1323
|
+
| `product.created` | New product created |
|
|
1324
|
+
| `product.updated` | Product details changed |
|
|
1325
|
+
| `product.deleted` | Product removed |
|
|
1326
|
+
| `inventory.updated` | Stock levels changed |
|
|
1327
|
+
| `order.created` | New order received |
|
|
1328
|
+
| `order.updated` | Order status changed |
|
|
1329
|
+
| `cart.abandoned` | Cart abandoned (no activity) |
|
|
1330
|
+
| `checkout.completed` | Checkout completed successfully |
|
|
1331
|
+
|
|
1332
|
+
---
|
|
1333
|
+
|
|
1334
|
+
## TypeScript Support
|
|
1335
|
+
|
|
1336
|
+
All types are exported for full TypeScript support:
|
|
1337
|
+
|
|
1338
|
+
```typescript
|
|
1339
|
+
import type {
|
|
1340
|
+
// Products
|
|
1341
|
+
Product,
|
|
1342
|
+
ProductImage,
|
|
1343
|
+
ProductVariant,
|
|
1344
|
+
InventoryInfo,
|
|
1345
|
+
ProductQueryParams,
|
|
1346
|
+
PaginatedResponse,
|
|
1347
|
+
|
|
1348
|
+
// Cart
|
|
1349
|
+
Cart,
|
|
1350
|
+
CartItem,
|
|
1351
|
+
AddToCartDto,
|
|
1352
|
+
|
|
1353
|
+
// Checkout
|
|
1354
|
+
Checkout,
|
|
1355
|
+
CheckoutStatus,
|
|
1356
|
+
ShippingRate,
|
|
1357
|
+
SetShippingAddressDto,
|
|
1358
|
+
|
|
1359
|
+
// Customer
|
|
1360
|
+
Customer,
|
|
1361
|
+
CustomerProfile,
|
|
1362
|
+
CustomerAddress,
|
|
1363
|
+
CustomerAuthResponse,
|
|
1364
|
+
|
|
1365
|
+
// Orders
|
|
1366
|
+
Order,
|
|
1367
|
+
OrderStatus,
|
|
1368
|
+
OrderItem,
|
|
1369
|
+
|
|
1370
|
+
// Webhooks
|
|
1371
|
+
WebhookEvent,
|
|
1372
|
+
WebhookEventType,
|
|
1373
|
+
|
|
1374
|
+
// Errors
|
|
1375
|
+
OmniSyncError,
|
|
1376
|
+
} from 'omni-sync-sdk';
|
|
1377
|
+
```
|
|
1378
|
+
|
|
1379
|
+
---
|
|
1380
|
+
|
|
1381
|
+
## Environment Variables
|
|
1382
|
+
|
|
1383
|
+
```env
|
|
1384
|
+
# Required for vibe-coded sites
|
|
1385
|
+
NEXT_PUBLIC_OMNI_CONNECTION_ID=vc_your_connection_id
|
|
1386
|
+
|
|
1387
|
+
# Optional: Override API URL (default: https://api.omni-sync.com)
|
|
1388
|
+
NEXT_PUBLIC_OMNI_API_URL=https://api.omni-sync.com
|
|
1389
|
+
|
|
1390
|
+
# For webhooks (server-side only)
|
|
1391
|
+
OMNI_SYNC_WEBHOOK_SECRET=your_webhook_secret
|
|
1392
|
+
```
|
|
1393
|
+
|
|
1394
|
+
---
|
|
1395
|
+
|
|
1396
|
+
## Required Pages Checklist
|
|
1397
|
+
|
|
1398
|
+
When building a store, implement these pages:
|
|
1399
|
+
|
|
1400
|
+
- [ ] **Home** (`/`) - Product grid
|
|
1401
|
+
- [ ] **Products** (`/products`) - Product list with pagination
|
|
1402
|
+
- [ ] **Product Detail** (`/products/[id]`) - Single product with Add to Cart
|
|
1403
|
+
- [ ] **Cart** (`/cart`) - Cart items, update quantity, remove
|
|
1404
|
+
- [ ] **Checkout** (`/checkout`) - Multi-step checkout flow
|
|
1405
|
+
- [ ] **Login** (`/login`) - Customer login
|
|
1406
|
+
- [ ] **Register** (`/register`) - Customer registration
|
|
1407
|
+
- [ ] **Account** (`/account`) - Profile and order history
|
|
1408
|
+
|
|
1409
|
+
---
|
|
1410
|
+
|
|
1411
|
+
## Important Rules
|
|
1412
|
+
|
|
1413
|
+
### DO:
|
|
1414
|
+
- Install `omni-sync-sdk` and use it for ALL data
|
|
1415
|
+
- Import types from the SDK
|
|
1416
|
+
- Handle loading states and errors
|
|
1417
|
+
- Persist cart ID in localStorage
|
|
1418
|
+
- Persist customer token after login
|
|
1419
|
+
|
|
1420
|
+
### DON'T:
|
|
1421
|
+
- Create mock/hardcoded product data
|
|
1422
|
+
- Use localStorage for products
|
|
1423
|
+
- Skip implementing required pages
|
|
1424
|
+
- Write `const products = [...]` - use the API!
|
|
1425
|
+
|
|
1426
|
+
---
|
|
1427
|
+
|
|
1428
|
+
## License
|
|
1429
|
+
|
|
1430
|
+
MIT
|