omni-sync-sdk 0.7.11 → 0.8.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 +658 -10
- package/dist/index.d.mts +155 -1
- package/dist/index.d.ts +155 -1
- package/dist/index.js +217 -0
- package/dist/index.mjs +217 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -30,8 +30,37 @@ const omni = new OmniSyncClient({
|
|
|
30
30
|
|
|
31
31
|
// Fetch products
|
|
32
32
|
const { data: products } = await omni.getProducts();
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## Checkout: Guest vs Logged-In Customer
|
|
38
|
+
|
|
39
|
+
> **IMPORTANT:** There are TWO different checkout flows. You MUST use the correct one based on whether the customer is logged in or not.
|
|
33
40
|
|
|
34
|
-
|
|
41
|
+
| Customer Type | Cart Type | Checkout Method | Orders Linked to Account? |
|
|
42
|
+
|---------------|-----------|-----------------|---------------------------|
|
|
43
|
+
| **Guest** | Local Cart (localStorage) | `submitGuestOrder()` | No |
|
|
44
|
+
| **Logged In** | Server Cart | `completeCheckout()` | Yes |
|
|
45
|
+
|
|
46
|
+
### Decision Flow
|
|
47
|
+
|
|
48
|
+
```typescript
|
|
49
|
+
// ALWAYS check this at checkout!
|
|
50
|
+
if (isLoggedIn()) {
|
|
51
|
+
// ✅ Logged-in customer → Server Cart + Checkout flow
|
|
52
|
+
// Orders will be linked to their account
|
|
53
|
+
const order = await completeServerCheckout();
|
|
54
|
+
} else {
|
|
55
|
+
// ✅ Guest → Local Cart + submitGuestOrder
|
|
56
|
+
// Orders are standalone (not linked to any account)
|
|
57
|
+
const order = await omni.submitGuestOrder();
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Guest Checkout (for visitors without account)
|
|
62
|
+
|
|
63
|
+
```typescript
|
|
35
64
|
// Cart stored locally - NO API calls until checkout!
|
|
36
65
|
|
|
37
66
|
// Add to local cart (stored in localStorage)
|
|
@@ -58,11 +87,53 @@ const order = await omni.submitGuestOrder();
|
|
|
58
87
|
console.log('Order created:', order.orderId);
|
|
59
88
|
```
|
|
60
89
|
|
|
90
|
+
### Logged-In Customer Checkout (orders linked to account)
|
|
91
|
+
|
|
92
|
+
```typescript
|
|
93
|
+
// 1. Make sure customer token is set (after login)
|
|
94
|
+
omni.setCustomerToken(authResponse.token);
|
|
95
|
+
|
|
96
|
+
// 2. Create server cart (auto-linked to customer!)
|
|
97
|
+
const cart = await omni.createCart();
|
|
98
|
+
localStorage.setItem('cartId', cart.id);
|
|
99
|
+
|
|
100
|
+
// 3. Add items to server cart
|
|
101
|
+
await omni.addToCart(cart.id, {
|
|
102
|
+
productId: products[0].id,
|
|
103
|
+
quantity: 1,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// 4. Create checkout from cart
|
|
107
|
+
const checkout = await omni.createCheckout({ cartId: cart.id });
|
|
108
|
+
|
|
109
|
+
// 5. Set shipping address
|
|
110
|
+
await omni.setShippingAddress(checkout.id, {
|
|
111
|
+
firstName: 'John',
|
|
112
|
+
lastName: 'Doe',
|
|
113
|
+
line1: '123 Main St',
|
|
114
|
+
city: 'New York',
|
|
115
|
+
postalCode: '10001',
|
|
116
|
+
country: 'US',
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// 6. Get shipping rates and select one
|
|
120
|
+
const rates = await omni.getShippingRates(checkout.id);
|
|
121
|
+
await omni.selectShippingMethod(checkout.id, rates[0].id);
|
|
122
|
+
|
|
123
|
+
// 7. Complete checkout - order is linked to customer!
|
|
124
|
+
const { orderId } = await omni.completeCheckout(checkout.id);
|
|
125
|
+
console.log('Order created:', orderId);
|
|
126
|
+
|
|
127
|
+
// Customer can now see this order in omni.getMyOrders()
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
> **WARNING:** Do NOT use `submitGuestOrder()` for logged-in customers! Their orders won't be linked to their account and won't appear in their order history.
|
|
131
|
+
|
|
61
132
|
---
|
|
62
133
|
|
|
63
134
|
## Two Ways to Handle Cart
|
|
64
135
|
|
|
65
|
-
### Option 1: Local Cart (Guest Users)
|
|
136
|
+
### Option 1: Local Cart (Guest Users)
|
|
66
137
|
|
|
67
138
|
For guest users, the cart is stored in **localStorage** - exactly like Amazon, Shopify, and other major platforms do. This means:
|
|
68
139
|
|
|
@@ -89,19 +160,34 @@ omni.removeFromLocalCart('prod_123');
|
|
|
89
160
|
const order = await omni.submitGuestOrder();
|
|
90
161
|
```
|
|
91
162
|
|
|
92
|
-
### Option 2: Server Cart (
|
|
163
|
+
### Option 2: Server Cart (Logged-In Customers)
|
|
93
164
|
|
|
94
|
-
For logged-in customers, use server-side cart:
|
|
165
|
+
For logged-in customers, **you MUST use server-side cart** to link orders to their account:
|
|
95
166
|
|
|
96
167
|
- ✅ Cart syncs across devices
|
|
97
168
|
- ✅ Abandoned cart recovery
|
|
98
|
-
- ✅
|
|
169
|
+
- ✅ Orders linked to customer account
|
|
170
|
+
- ✅ Customer can see orders in "My Orders"
|
|
99
171
|
|
|
100
172
|
```typescript
|
|
173
|
+
// 1. Set customer token (after login)
|
|
174
|
+
omni.setCustomerToken(token);
|
|
175
|
+
|
|
176
|
+
// 2. Create cart (auto-linked to customer)
|
|
101
177
|
const cart = await omni.createCart();
|
|
178
|
+
localStorage.setItem('cartId', cart.id);
|
|
179
|
+
|
|
180
|
+
// 3. Add items
|
|
102
181
|
await omni.addToCart(cart.id, { productId: 'prod_123', quantity: 2 });
|
|
182
|
+
|
|
183
|
+
// 4. At checkout - create checkout and complete
|
|
184
|
+
const checkout = await omni.createCheckout({ cartId: cart.id });
|
|
185
|
+
// ... set shipping address, select shipping method ...
|
|
186
|
+
const { orderId } = await omni.completeCheckout(checkout.id);
|
|
103
187
|
```
|
|
104
188
|
|
|
189
|
+
> **⚠️ CRITICAL:** If you use `submitGuestOrder()` for a logged-in customer, their order will NOT be linked to their account!
|
|
190
|
+
|
|
105
191
|
---
|
|
106
192
|
|
|
107
193
|
## Complete Store Setup
|
|
@@ -322,6 +408,62 @@ interface InventoryInfo {
|
|
|
322
408
|
}
|
|
323
409
|
```
|
|
324
410
|
|
|
411
|
+
#### Displaying Price Range for Variable Products
|
|
412
|
+
|
|
413
|
+
For products with `type: 'VARIABLE'` and multiple variants with different prices, display a **price range** instead of a single price:
|
|
414
|
+
|
|
415
|
+
```typescript
|
|
416
|
+
// Helper function to get price range from variants
|
|
417
|
+
function getPriceRange(product: Product): { min: number; max: number } | null {
|
|
418
|
+
if (product.type !== 'VARIABLE' || !product.variants?.length) {
|
|
419
|
+
return null;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const prices = product.variants
|
|
423
|
+
.map(v => v.price ?? product.basePrice)
|
|
424
|
+
.filter((p): p is number => p !== null);
|
|
425
|
+
|
|
426
|
+
if (prices.length === 0) return null;
|
|
427
|
+
|
|
428
|
+
const min = Math.min(...prices);
|
|
429
|
+
const max = Math.max(...prices);
|
|
430
|
+
|
|
431
|
+
// Return null if all variants have the same price
|
|
432
|
+
return min !== max ? { min, max } : null;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Usage in component
|
|
436
|
+
function ProductPrice({ product }: { product: Product }) {
|
|
437
|
+
const priceRange = getPriceRange(product);
|
|
438
|
+
|
|
439
|
+
if (priceRange) {
|
|
440
|
+
// Variable product with different variant prices - show range
|
|
441
|
+
return <span>${priceRange.min} - ${priceRange.max}</span>;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Simple product or all variants same price - show single price
|
|
445
|
+
return product.salePrice ? (
|
|
446
|
+
<>
|
|
447
|
+
<span className="text-red-600">${product.salePrice}</span>
|
|
448
|
+
<span className="line-through text-gray-400 ml-2">${product.basePrice}</span>
|
|
449
|
+
</>
|
|
450
|
+
) : (
|
|
451
|
+
<span>${product.basePrice}</span>
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
**When to show price range:**
|
|
457
|
+
|
|
458
|
+
- Product `type` is `'VARIABLE'`
|
|
459
|
+
- Has 2+ variants with **different** prices
|
|
460
|
+
- Example: T-shirt sizes S/M/L at $29, XL/XXL at $34 → Display "$29 - $34"
|
|
461
|
+
|
|
462
|
+
**When to show single price:**
|
|
463
|
+
|
|
464
|
+
- Product `type` is `'SIMPLE'`
|
|
465
|
+
- Variable product where all variants have the same price
|
|
466
|
+
|
|
325
467
|
#### Rendering Product Descriptions
|
|
326
468
|
|
|
327
469
|
**IMPORTANT**: Product descriptions may contain HTML (from Shopify/WooCommerce) or plain text. Always check `descriptionFormat` before rendering:
|
|
@@ -1076,6 +1218,335 @@ export default function VerifyEmailPage() {
|
|
|
1076
1218
|
|
|
1077
1219
|
---
|
|
1078
1220
|
|
|
1221
|
+
### Social Login (OAuth)
|
|
1222
|
+
|
|
1223
|
+
Allow customers to sign in with Google, Facebook, or GitHub. The store owner configures which providers are available in their OmniSync admin panel.
|
|
1224
|
+
|
|
1225
|
+
#### Check Available Providers
|
|
1226
|
+
|
|
1227
|
+
```typescript
|
|
1228
|
+
// Returns only the providers the store owner has enabled
|
|
1229
|
+
const { providers } = await omni.getAvailableOAuthProviders();
|
|
1230
|
+
// providers = ['GOOGLE', 'FACEBOOK'] - varies by store configuration
|
|
1231
|
+
```
|
|
1232
|
+
|
|
1233
|
+
#### OAuth Login Flow
|
|
1234
|
+
|
|
1235
|
+
**Step 1: User clicks "Sign in with Google"**
|
|
1236
|
+
|
|
1237
|
+
```typescript
|
|
1238
|
+
// Save cart ID before redirect (user will leave your site!)
|
|
1239
|
+
const cartId = localStorage.getItem('cartId');
|
|
1240
|
+
if (cartId) sessionStorage.setItem('pendingCartId', cartId);
|
|
1241
|
+
|
|
1242
|
+
// Get authorization URL
|
|
1243
|
+
const { authorizationUrl, state } = await omni.getOAuthAuthorizeUrl('GOOGLE', {
|
|
1244
|
+
redirectUrl: window.location.origin + '/auth/callback', // Where Google sends them back
|
|
1245
|
+
});
|
|
1246
|
+
|
|
1247
|
+
// Save state for verification (CSRF protection)
|
|
1248
|
+
sessionStorage.setItem('oauthState', state);
|
|
1249
|
+
|
|
1250
|
+
// Redirect to Google
|
|
1251
|
+
window.location.href = authorizationUrl;
|
|
1252
|
+
```
|
|
1253
|
+
|
|
1254
|
+
**Step 2: Create callback page (`/auth/callback`)**
|
|
1255
|
+
|
|
1256
|
+
```typescript
|
|
1257
|
+
// pages/auth/callback.tsx or app/auth/callback/page.tsx
|
|
1258
|
+
'use client';
|
|
1259
|
+
import { useEffect, useState } from 'react';
|
|
1260
|
+
import { useSearchParams } from 'next/navigation';
|
|
1261
|
+
import { omni, setCustomerToken } from '@/lib/omni-sync';
|
|
1262
|
+
|
|
1263
|
+
export default function AuthCallback() {
|
|
1264
|
+
const searchParams = useSearchParams();
|
|
1265
|
+
const [error, setError] = useState<string | null>(null);
|
|
1266
|
+
|
|
1267
|
+
useEffect(() => {
|
|
1268
|
+
async function handleCallback() {
|
|
1269
|
+
const code = searchParams.get('code');
|
|
1270
|
+
const state = searchParams.get('state');
|
|
1271
|
+
const errorParam = searchParams.get('error');
|
|
1272
|
+
|
|
1273
|
+
// Check for OAuth errors (user cancelled, etc.)
|
|
1274
|
+
if (errorParam) {
|
|
1275
|
+
window.location.href = '/login?error=cancelled';
|
|
1276
|
+
return;
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
if (!code || !state) {
|
|
1280
|
+
setError('Missing OAuth parameters');
|
|
1281
|
+
return;
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
// Verify state matches (CSRF protection)
|
|
1285
|
+
const savedState = sessionStorage.getItem('oauthState');
|
|
1286
|
+
if (state !== savedState) {
|
|
1287
|
+
setError('Invalid state - please try again');
|
|
1288
|
+
return;
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
try {
|
|
1292
|
+
// Exchange code for customer token
|
|
1293
|
+
const { customer, token, isNewCustomer } = await omni.handleOAuthCallback(
|
|
1294
|
+
'GOOGLE',
|
|
1295
|
+
code,
|
|
1296
|
+
state
|
|
1297
|
+
);
|
|
1298
|
+
|
|
1299
|
+
// Save the customer token
|
|
1300
|
+
setCustomerToken(token);
|
|
1301
|
+
sessionStorage.removeItem('oauthState');
|
|
1302
|
+
|
|
1303
|
+
// Link any pending cart to the now-logged-in customer
|
|
1304
|
+
const pendingCartId = sessionStorage.getItem('pendingCartId');
|
|
1305
|
+
if (pendingCartId) {
|
|
1306
|
+
try {
|
|
1307
|
+
await omni.linkCart(pendingCartId);
|
|
1308
|
+
} catch {
|
|
1309
|
+
// Cart may have expired - that's ok
|
|
1310
|
+
}
|
|
1311
|
+
sessionStorage.removeItem('pendingCartId');
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
// Redirect to return URL or home
|
|
1315
|
+
const returnUrl = localStorage.getItem('returnUrl') || '/';
|
|
1316
|
+
localStorage.removeItem('returnUrl');
|
|
1317
|
+
window.location.href = returnUrl;
|
|
1318
|
+
} catch (err) {
|
|
1319
|
+
setError(err instanceof Error ? err.message : 'Login failed');
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
handleCallback();
|
|
1324
|
+
}, [searchParams]);
|
|
1325
|
+
|
|
1326
|
+
if (error) {
|
|
1327
|
+
return (
|
|
1328
|
+
<div className="max-w-md mx-auto mt-12 text-center">
|
|
1329
|
+
<h1 className="text-xl font-bold text-red-600">Login Failed</h1>
|
|
1330
|
+
<p className="mt-2 text-gray-600">{error}</p>
|
|
1331
|
+
<a href="/login" className="mt-4 inline-block text-blue-600">
|
|
1332
|
+
Try again
|
|
1333
|
+
</a>
|
|
1334
|
+
</div>
|
|
1335
|
+
);
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
return (
|
|
1339
|
+
<div className="max-w-md mx-auto mt-12 text-center">
|
|
1340
|
+
<div className="animate-spin h-8 w-8 border-4 border-blue-600 border-t-transparent rounded-full mx-auto"></div>
|
|
1341
|
+
<p className="mt-4 text-gray-600">Completing login...</p>
|
|
1342
|
+
</div>
|
|
1343
|
+
);
|
|
1344
|
+
}
|
|
1345
|
+
```
|
|
1346
|
+
|
|
1347
|
+
#### Login Page with Social Buttons
|
|
1348
|
+
|
|
1349
|
+
```typescript
|
|
1350
|
+
'use client';
|
|
1351
|
+
import { useState, useEffect } from 'react';
|
|
1352
|
+
import { omni, setCustomerToken } from '@/lib/omni-sync';
|
|
1353
|
+
|
|
1354
|
+
export default function LoginPage() {
|
|
1355
|
+
const [providers, setProviders] = useState<string[]>([]);
|
|
1356
|
+
const [email, setEmail] = useState('');
|
|
1357
|
+
const [password, setPassword] = useState('');
|
|
1358
|
+
const [loading, setLoading] = useState(false);
|
|
1359
|
+
const [error, setError] = useState('');
|
|
1360
|
+
|
|
1361
|
+
// Load available OAuth providers
|
|
1362
|
+
useEffect(() => {
|
|
1363
|
+
omni.getAvailableOAuthProviders()
|
|
1364
|
+
.then(({ providers }) => setProviders(providers))
|
|
1365
|
+
.catch(() => {}); // OAuth not configured - that's ok
|
|
1366
|
+
}, []);
|
|
1367
|
+
|
|
1368
|
+
// Social login handler
|
|
1369
|
+
const handleSocialLogin = async (provider: string) => {
|
|
1370
|
+
try {
|
|
1371
|
+
// Save cart ID before redirect
|
|
1372
|
+
const cartId = localStorage.getItem('cartId');
|
|
1373
|
+
if (cartId) sessionStorage.setItem('pendingCartId', cartId);
|
|
1374
|
+
|
|
1375
|
+
const { authorizationUrl, state } = await omni.getOAuthAuthorizeUrl(
|
|
1376
|
+
provider as 'GOOGLE' | 'FACEBOOK' | 'GITHUB',
|
|
1377
|
+
{ redirectUrl: window.location.origin + '/auth/callback' }
|
|
1378
|
+
);
|
|
1379
|
+
|
|
1380
|
+
sessionStorage.setItem('oauthState', state);
|
|
1381
|
+
window.location.href = authorizationUrl;
|
|
1382
|
+
} catch (err) {
|
|
1383
|
+
setError('Failed to start login');
|
|
1384
|
+
}
|
|
1385
|
+
};
|
|
1386
|
+
|
|
1387
|
+
// Regular email/password login
|
|
1388
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
1389
|
+
e.preventDefault();
|
|
1390
|
+
setLoading(true);
|
|
1391
|
+
setError('');
|
|
1392
|
+
try {
|
|
1393
|
+
const auth = await omni.loginCustomer(email, password);
|
|
1394
|
+
setCustomerToken(auth.token);
|
|
1395
|
+
const returnUrl = localStorage.getItem('returnUrl') || '/';
|
|
1396
|
+
localStorage.removeItem('returnUrl');
|
|
1397
|
+
window.location.href = returnUrl;
|
|
1398
|
+
} catch (err) {
|
|
1399
|
+
setError('Invalid email or password');
|
|
1400
|
+
} finally {
|
|
1401
|
+
setLoading(false);
|
|
1402
|
+
}
|
|
1403
|
+
};
|
|
1404
|
+
|
|
1405
|
+
return (
|
|
1406
|
+
<div className="max-w-md mx-auto mt-12">
|
|
1407
|
+
<h1 className="text-2xl font-bold mb-6">Login</h1>
|
|
1408
|
+
|
|
1409
|
+
{/* Social Login Buttons */}
|
|
1410
|
+
{providers.length > 0 && (
|
|
1411
|
+
<div className="space-y-3 mb-6">
|
|
1412
|
+
{providers.includes('GOOGLE') && (
|
|
1413
|
+
<button
|
|
1414
|
+
onClick={() => handleSocialLogin('GOOGLE')}
|
|
1415
|
+
className="w-full flex items-center justify-center gap-2 border py-3 rounded hover:bg-gray-50"
|
|
1416
|
+
>
|
|
1417
|
+
<GoogleIcon />
|
|
1418
|
+
Continue with Google
|
|
1419
|
+
</button>
|
|
1420
|
+
)}
|
|
1421
|
+
{providers.includes('FACEBOOK') && (
|
|
1422
|
+
<button
|
|
1423
|
+
onClick={() => handleSocialLogin('FACEBOOK')}
|
|
1424
|
+
className="w-full flex items-center justify-center gap-2 bg-[#1877F2] text-white py-3 rounded"
|
|
1425
|
+
>
|
|
1426
|
+
<FacebookIcon />
|
|
1427
|
+
Continue with Facebook
|
|
1428
|
+
</button>
|
|
1429
|
+
)}
|
|
1430
|
+
{providers.includes('GITHUB') && (
|
|
1431
|
+
<button
|
|
1432
|
+
onClick={() => handleSocialLogin('GITHUB')}
|
|
1433
|
+
className="w-full flex items-center justify-center gap-2 bg-[#24292F] text-white py-3 rounded"
|
|
1434
|
+
>
|
|
1435
|
+
<GithubIcon />
|
|
1436
|
+
Continue with GitHub
|
|
1437
|
+
</button>
|
|
1438
|
+
)}
|
|
1439
|
+
<div className="relative my-4">
|
|
1440
|
+
<div className="absolute inset-0 flex items-center">
|
|
1441
|
+
<div className="w-full border-t" />
|
|
1442
|
+
</div>
|
|
1443
|
+
<div className="relative flex justify-center text-sm">
|
|
1444
|
+
<span className="px-2 bg-white text-gray-500">or</span>
|
|
1445
|
+
</div>
|
|
1446
|
+
</div>
|
|
1447
|
+
</div>
|
|
1448
|
+
)}
|
|
1449
|
+
|
|
1450
|
+
{/* Email/Password Form */}
|
|
1451
|
+
{error && <div className="bg-red-100 text-red-600 p-3 rounded mb-4">{error}</div>}
|
|
1452
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
1453
|
+
<input
|
|
1454
|
+
type="email"
|
|
1455
|
+
placeholder="Email"
|
|
1456
|
+
value={email}
|
|
1457
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
1458
|
+
required
|
|
1459
|
+
className="w-full border p-2 rounded"
|
|
1460
|
+
/>
|
|
1461
|
+
<input
|
|
1462
|
+
type="password"
|
|
1463
|
+
placeholder="Password"
|
|
1464
|
+
value={password}
|
|
1465
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
1466
|
+
required
|
|
1467
|
+
className="w-full border p-2 rounded"
|
|
1468
|
+
/>
|
|
1469
|
+
<button
|
|
1470
|
+
type="submit"
|
|
1471
|
+
disabled={loading}
|
|
1472
|
+
className="w-full bg-black text-white py-3 rounded"
|
|
1473
|
+
>
|
|
1474
|
+
{loading ? 'Logging in...' : 'Login'}
|
|
1475
|
+
</button>
|
|
1476
|
+
</form>
|
|
1477
|
+
|
|
1478
|
+
<p className="mt-4 text-center">
|
|
1479
|
+
Don't have an account? <a href="/register" className="text-blue-600">Register</a>
|
|
1480
|
+
</p>
|
|
1481
|
+
</div>
|
|
1482
|
+
);
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
// Simple SVG icons (or use lucide-react)
|
|
1486
|
+
const GoogleIcon = () => (
|
|
1487
|
+
<svg className="w-5 h-5" viewBox="0 0 24 24">
|
|
1488
|
+
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
|
|
1489
|
+
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
|
|
1490
|
+
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
|
|
1491
|
+
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
|
|
1492
|
+
</svg>
|
|
1493
|
+
);
|
|
1494
|
+
|
|
1495
|
+
const FacebookIcon = () => (
|
|
1496
|
+
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
|
1497
|
+
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
|
|
1498
|
+
</svg>
|
|
1499
|
+
);
|
|
1500
|
+
|
|
1501
|
+
const GithubIcon = () => (
|
|
1502
|
+
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
|
1503
|
+
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
|
1504
|
+
</svg>
|
|
1505
|
+
);
|
|
1506
|
+
```
|
|
1507
|
+
|
|
1508
|
+
#### Account Linking (Add Social Account to Existing User)
|
|
1509
|
+
|
|
1510
|
+
Logged-in customers can link additional social accounts to their profile:
|
|
1511
|
+
|
|
1512
|
+
```typescript
|
|
1513
|
+
// Get currently linked accounts
|
|
1514
|
+
const connections = await omni.getOAuthConnections();
|
|
1515
|
+
// [{ provider: 'GOOGLE', email: 'user@gmail.com', linkedAt: '...' }]
|
|
1516
|
+
|
|
1517
|
+
// Link a new provider (redirects to OAuth flow)
|
|
1518
|
+
const { authorizationUrl } = await omni.linkOAuthProvider('GITHUB');
|
|
1519
|
+
window.location.href = authorizationUrl;
|
|
1520
|
+
|
|
1521
|
+
// Unlink a provider
|
|
1522
|
+
await omni.unlinkOAuthProvider('GOOGLE');
|
|
1523
|
+
```
|
|
1524
|
+
|
|
1525
|
+
#### Cart Linking After OAuth Login
|
|
1526
|
+
|
|
1527
|
+
When a customer logs in via OAuth, their guest cart should be linked to their account:
|
|
1528
|
+
|
|
1529
|
+
```typescript
|
|
1530
|
+
// omni.linkCart() associates a guest cart with the logged-in customer
|
|
1531
|
+
await omni.linkCart(cartId);
|
|
1532
|
+
```
|
|
1533
|
+
|
|
1534
|
+
This is automatically handled in the callback example above.
|
|
1535
|
+
|
|
1536
|
+
#### OAuth Method Reference
|
|
1537
|
+
|
|
1538
|
+
| Method | Description |
|
|
1539
|
+
| -------------------------------------------- | -------------------------------------------- |
|
|
1540
|
+
| `getAvailableOAuthProviders()` | Get list of enabled providers for this store |
|
|
1541
|
+
| `getOAuthAuthorizeUrl(provider, options?)` | Get URL to redirect user to OAuth provider |
|
|
1542
|
+
| `handleOAuthCallback(provider, code, state)` | Exchange OAuth code for customer token |
|
|
1543
|
+
| `linkOAuthProvider(provider)` | Link social account to current customer |
|
|
1544
|
+
| `unlinkOAuthProvider(provider)` | Remove linked social account |
|
|
1545
|
+
| `getOAuthConnections()` | Get list of linked social accounts |
|
|
1546
|
+
| `linkCart(cartId)` | Link guest cart to logged-in customer |
|
|
1547
|
+
|
|
1548
|
+
---
|
|
1549
|
+
|
|
1079
1550
|
### Customer Addresses
|
|
1080
1551
|
|
|
1081
1552
|
#### Get Addresses
|
|
@@ -1460,9 +1931,183 @@ export default function CartPage() {
|
|
|
1460
1931
|
}
|
|
1461
1932
|
```
|
|
1462
1933
|
|
|
1934
|
+
### Universal Checkout (Handles Both Guest & Logged-In)
|
|
1935
|
+
|
|
1936
|
+
> **RECOMMENDED:** Use this pattern to properly handle both guest and logged-in customers in a single checkout page.
|
|
1937
|
+
|
|
1938
|
+
```typescript
|
|
1939
|
+
'use client';
|
|
1940
|
+
import { useState, useEffect } from 'react';
|
|
1941
|
+
import { omni, isLoggedIn, getServerCartId, setServerCartId, restoreCustomerToken } from '@/lib/omni-sync';
|
|
1942
|
+
|
|
1943
|
+
export default function CheckoutPage() {
|
|
1944
|
+
const [loading, setLoading] = useState(true);
|
|
1945
|
+
const [submitting, setSubmitting] = useState(false);
|
|
1946
|
+
const [customerLoggedIn, setCustomerLoggedIn] = useState(false);
|
|
1947
|
+
|
|
1948
|
+
// Form state
|
|
1949
|
+
const [email, setEmail] = useState('');
|
|
1950
|
+
const [shippingAddress, setShippingAddress] = useState({
|
|
1951
|
+
firstName: '', lastName: '', line1: '', city: '', postalCode: '', country: 'US'
|
|
1952
|
+
});
|
|
1953
|
+
|
|
1954
|
+
// Server checkout state (for logged-in customers)
|
|
1955
|
+
const [checkoutId, setCheckoutId] = useState<string | null>(null);
|
|
1956
|
+
const [shippingRates, setShippingRates] = useState<any[]>([]);
|
|
1957
|
+
const [selectedRate, setSelectedRate] = useState<string | null>(null);
|
|
1958
|
+
|
|
1959
|
+
useEffect(() => {
|
|
1960
|
+
restoreCustomerToken();
|
|
1961
|
+
const loggedIn = isLoggedIn();
|
|
1962
|
+
setCustomerLoggedIn(loggedIn);
|
|
1963
|
+
|
|
1964
|
+
async function initCheckout() {
|
|
1965
|
+
if (loggedIn) {
|
|
1966
|
+
// Logged-in customer: Create server cart + checkout
|
|
1967
|
+
let cartId = getServerCartId();
|
|
1968
|
+
|
|
1969
|
+
if (!cartId) {
|
|
1970
|
+
// Create new cart (auto-linked to customer)
|
|
1971
|
+
const cart = await omni.createCart();
|
|
1972
|
+
cartId = cart.id;
|
|
1973
|
+
setServerCartId(cartId);
|
|
1974
|
+
|
|
1975
|
+
// Migrate local cart items to server cart
|
|
1976
|
+
const localCart = omni.getLocalCart();
|
|
1977
|
+
for (const item of localCart.items) {
|
|
1978
|
+
await omni.addToCart(cartId, {
|
|
1979
|
+
productId: item.productId,
|
|
1980
|
+
variantId: item.variantId,
|
|
1981
|
+
quantity: item.quantity,
|
|
1982
|
+
});
|
|
1983
|
+
}
|
|
1984
|
+
omni.clearLocalCart(); // Clear local cart after migration
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
// Create checkout from server cart
|
|
1988
|
+
const checkout = await omni.createCheckout({ cartId });
|
|
1989
|
+
setCheckoutId(checkout.id);
|
|
1990
|
+
|
|
1991
|
+
// Pre-fill from customer profile if available
|
|
1992
|
+
try {
|
|
1993
|
+
const profile = await omni.getMyOrders({ limit: 1 }); // Just to check auth works
|
|
1994
|
+
} catch (e) {
|
|
1995
|
+
console.log('Could not fetch profile');
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
setLoading(false);
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
initCheckout();
|
|
2002
|
+
}, []);
|
|
2003
|
+
|
|
2004
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
2005
|
+
e.preventDefault();
|
|
2006
|
+
setSubmitting(true);
|
|
2007
|
+
|
|
2008
|
+
try {
|
|
2009
|
+
if (customerLoggedIn && checkoutId) {
|
|
2010
|
+
// ===== LOGGED-IN CUSTOMER: Server Checkout =====
|
|
2011
|
+
|
|
2012
|
+
// 1. Set shipping address
|
|
2013
|
+
await omni.setShippingAddress(checkoutId, shippingAddress);
|
|
2014
|
+
|
|
2015
|
+
// 2. Get and select shipping rate
|
|
2016
|
+
const rates = await omni.getShippingRates(checkoutId);
|
|
2017
|
+
if (rates.length > 0) {
|
|
2018
|
+
await omni.selectShippingMethod(checkoutId, selectedRate || rates[0].id);
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
// 3. Complete checkout - ORDER IS LINKED TO CUSTOMER!
|
|
2022
|
+
const { orderId } = await omni.completeCheckout(checkoutId);
|
|
2023
|
+
|
|
2024
|
+
// Clear cart ID
|
|
2025
|
+
localStorage.removeItem('cartId');
|
|
2026
|
+
|
|
2027
|
+
// Redirect to success page
|
|
2028
|
+
window.location.href = `/order-success?orderId=${orderId}`;
|
|
2029
|
+
|
|
2030
|
+
} else {
|
|
2031
|
+
// ===== GUEST: Local Cart + submitGuestOrder =====
|
|
2032
|
+
|
|
2033
|
+
// Set customer and shipping info on local cart
|
|
2034
|
+
omni.setLocalCartCustomer({ email });
|
|
2035
|
+
omni.setLocalCartShippingAddress(shippingAddress);
|
|
2036
|
+
|
|
2037
|
+
// Submit guest order (single API call)
|
|
2038
|
+
const order = await omni.submitGuestOrder();
|
|
2039
|
+
|
|
2040
|
+
// Redirect to success page
|
|
2041
|
+
window.location.href = `/order-success?orderId=${order.orderId}`;
|
|
2042
|
+
}
|
|
2043
|
+
} catch (error) {
|
|
2044
|
+
console.error('Checkout failed:', error);
|
|
2045
|
+
alert('Checkout failed. Please try again.');
|
|
2046
|
+
} finally {
|
|
2047
|
+
setSubmitting(false);
|
|
2048
|
+
}
|
|
2049
|
+
};
|
|
2050
|
+
|
|
2051
|
+
if (loading) return <div>Loading checkout...</div>;
|
|
2052
|
+
|
|
2053
|
+
return (
|
|
2054
|
+
<form onSubmit={handleSubmit}>
|
|
2055
|
+
{/* Show email field only for guests */}
|
|
2056
|
+
{!customerLoggedIn && (
|
|
2057
|
+
<input
|
|
2058
|
+
type="email"
|
|
2059
|
+
value={email}
|
|
2060
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
2061
|
+
placeholder="Email"
|
|
2062
|
+
required
|
|
2063
|
+
/>
|
|
2064
|
+
)}
|
|
2065
|
+
|
|
2066
|
+
{/* Shipping address fields */}
|
|
2067
|
+
<input value={shippingAddress.firstName} onChange={(e) => setShippingAddress({...shippingAddress, firstName: e.target.value})} placeholder="First Name" required />
|
|
2068
|
+
<input value={shippingAddress.lastName} onChange={(e) => setShippingAddress({...shippingAddress, lastName: e.target.value})} placeholder="Last Name" required />
|
|
2069
|
+
<input value={shippingAddress.line1} onChange={(e) => setShippingAddress({...shippingAddress, line1: e.target.value})} placeholder="Address" required />
|
|
2070
|
+
<input value={shippingAddress.city} onChange={(e) => setShippingAddress({...shippingAddress, city: e.target.value})} placeholder="City" required />
|
|
2071
|
+
<input value={shippingAddress.postalCode} onChange={(e) => setShippingAddress({...shippingAddress, postalCode: e.target.value})} placeholder="Postal Code" required />
|
|
2072
|
+
|
|
2073
|
+
{/* Shipping rates (for logged-in customers) */}
|
|
2074
|
+
{customerLoggedIn && shippingRates.length > 0 && (
|
|
2075
|
+
<select value={selectedRate || ''} onChange={(e) => setSelectedRate(e.target.value)}>
|
|
2076
|
+
{shippingRates.map((rate) => (
|
|
2077
|
+
<option key={rate.id} value={rate.id}>
|
|
2078
|
+
{rate.name} - ${rate.price}
|
|
2079
|
+
</option>
|
|
2080
|
+
))}
|
|
2081
|
+
</select>
|
|
2082
|
+
)}
|
|
2083
|
+
|
|
2084
|
+
<button type="submit" disabled={submitting}>
|
|
2085
|
+
{submitting ? 'Processing...' : 'Place Order'}
|
|
2086
|
+
</button>
|
|
2087
|
+
|
|
2088
|
+
{customerLoggedIn && (
|
|
2089
|
+
<p className="text-sm text-green-600">
|
|
2090
|
+
✓ Logged in - Order will be saved to your account
|
|
2091
|
+
</p>
|
|
2092
|
+
)}
|
|
2093
|
+
</form>
|
|
2094
|
+
);
|
|
2095
|
+
}
|
|
2096
|
+
```
|
|
2097
|
+
|
|
2098
|
+
> **Key Points:**
|
|
2099
|
+
> - `isLoggedIn()` determines which flow to use
|
|
2100
|
+
> - Logged-in customers use `createCart()` → `createCheckout()` → `completeCheckout()`
|
|
2101
|
+
> - Guests use local cart + `submitGuestOrder()`
|
|
2102
|
+
> - Local cart items are migrated to server cart when customer logs in
|
|
2103
|
+
|
|
2104
|
+
---
|
|
2105
|
+
|
|
1463
2106
|
### Guest Checkout (Single API Call)
|
|
1464
2107
|
|
|
1465
|
-
This is
|
|
2108
|
+
This checkout is for **guest users only**. All cart data is in localStorage, and we submit it in one API call.
|
|
2109
|
+
|
|
2110
|
+
> **⚠️ WARNING:** Do NOT use this for logged-in customers! Use the Universal Checkout pattern above instead.
|
|
1466
2111
|
|
|
1467
2112
|
```typescript
|
|
1468
2113
|
'use client';
|
|
@@ -1628,9 +2273,11 @@ export default function CheckoutPage() {
|
|
|
1628
2273
|
}
|
|
1629
2274
|
```
|
|
1630
2275
|
|
|
1631
|
-
### Multi-Step Checkout (Server Cart - For
|
|
2276
|
+
### Multi-Step Checkout (Server Cart - For Logged-In Customers Only)
|
|
2277
|
+
|
|
2278
|
+
> **IMPORTANT:** This checkout pattern is ONLY for logged-in customers. For a checkout page that handles both guests and logged-in customers, see the "Universal Checkout" example above.
|
|
1632
2279
|
|
|
1633
|
-
For logged-in users with server-side cart:
|
|
2280
|
+
For logged-in users with server-side cart - orders will be linked to their account:
|
|
1634
2281
|
|
|
1635
2282
|
```typescript
|
|
1636
2283
|
'use client';
|
|
@@ -2188,8 +2835,9 @@ When building a store, implement these pages:
|
|
|
2188
2835
|
- [ ] **Product Detail** (`/products/[id]`) - Single product with Add to Cart
|
|
2189
2836
|
- [ ] **Cart** (`/cart`) - Cart items, update quantity, remove
|
|
2190
2837
|
- [ ] **Checkout** (`/checkout`) - Multi-step checkout flow
|
|
2191
|
-
- [ ] **Login** (`/login`) - Customer login
|
|
2192
|
-
- [ ] **Register** (`/register`) - Customer registration
|
|
2838
|
+
- [ ] **Login** (`/login`) - Customer login + social login buttons (Google/Facebook/GitHub if available)
|
|
2839
|
+
- [ ] **Register** (`/register`) - Customer registration + social signup buttons
|
|
2840
|
+
- [ ] **Auth Callback** (`/auth/callback`) - Handle OAuth redirects from Google/Facebook/GitHub
|
|
2193
2841
|
- [ ] **Verify Email** (`/verify-email`) - Email verification with 6-digit code (if store requires it)
|
|
2194
2842
|
- [ ] **Account** (`/account`) - Profile and order history
|
|
2195
2843
|
|