brainerce 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AI_BUILDER_PROMPT.md +716 -0
- package/README.md +4409 -0
- package/dist/index.d.mts +6219 -0
- package/dist/index.d.ts +6219 -0
- package/dist/index.js +5462 -0
- package/dist/index.mjs +5418 -0
- package/package.json +77 -0
package/README.md
ADDED
|
@@ -0,0 +1,4409 @@
|
|
|
1
|
+
# brainerce
|
|
2
|
+
|
|
3
|
+
Official SDK for building e-commerce storefronts with **Brainerce 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 Brainerce's unified commerce API.
|
|
6
|
+
|
|
7
|
+
> **🤖 AI Agents / Vibe Coders:** See [AI_BUILDER_PROMPT.md](./AI_BUILDER_PROMPT.md) for a concise, copy-paste-ready prompt optimized for AI code generation. It contains the essential rules and complete code examples to build a working store.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install brainerce
|
|
13
|
+
# or
|
|
14
|
+
pnpm add brainerce
|
|
15
|
+
# or
|
|
16
|
+
yarn add brainerce
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Quick Reference - Helper Functions
|
|
22
|
+
|
|
23
|
+
The SDK exports these utility functions for common UI tasks:
|
|
24
|
+
|
|
25
|
+
| Function | Purpose | Example |
|
|
26
|
+
| ---------------------------------------------- | -------------------------------------- | ------------------------------------------------------ |
|
|
27
|
+
| `formatPrice(amount, { currency?, locale? })` | Format prices for display | `formatPrice("99.99", { currency: 'USD' })` → `$99.99` |
|
|
28
|
+
| `getPriceDisplay(amount, currency?, locale?)` | Alias for `formatPrice` | Same as above |
|
|
29
|
+
| `getDescriptionContent(product)` | Get product description (HTML or text) | `getDescriptionContent(product)` |
|
|
30
|
+
| `isHtmlDescription(product)` | Check if description is HTML | `isHtmlDescription(product)` → `true/false` |
|
|
31
|
+
| `getStockStatus(inventory)` | Get human-readable stock status | `getStockStatus(inventory)` → `"In Stock"` |
|
|
32
|
+
| `getProductPrice(product)` | Get effective price (handles sales) | `getProductPrice(product)` → `29.99` |
|
|
33
|
+
| `getProductPriceInfo(product)` | Get price + sale info + discount % | `{ price, isOnSale, discountPercent }` |
|
|
34
|
+
| `getVariantPrice(variant, basePrice)` | Get variant price with fallback | `getVariantPrice(variant, '29.99')` → `34.99` |
|
|
35
|
+
| `getCartTotals(cart, shippingPrice?)` | Calculate cart subtotal/discount/total | `{ subtotal, discount, shipping, total }` |
|
|
36
|
+
| `getCartItemName(item)` | Get name from nested cart item | `getCartItemName(item)` → `"Blue T-Shirt"` |
|
|
37
|
+
| `getCartItemImage(item)` | Get image URL from cart item | `getCartItemImage(item)` → `"https://..."` |
|
|
38
|
+
| `getVariantOptions(variant)` | Get variant attributes as array | `[{ name: "Color", value: "Red" }]` |
|
|
39
|
+
| `isCouponApplicableToProduct(coupon, product)` | Check if coupon applies | `isCouponApplicableToProduct(coupon, product)` |
|
|
40
|
+
|
|
41
|
+
```typescript
|
|
42
|
+
import {
|
|
43
|
+
formatPrice,
|
|
44
|
+
getDescriptionContent,
|
|
45
|
+
getStockStatus,
|
|
46
|
+
getProductPrice,
|
|
47
|
+
getProductPriceInfo,
|
|
48
|
+
getCartTotals,
|
|
49
|
+
getCartItemName,
|
|
50
|
+
getCartItemImage,
|
|
51
|
+
} from 'brainerce';
|
|
52
|
+
|
|
53
|
+
// Format price for display
|
|
54
|
+
const priceText = formatPrice(product.basePrice, { currency: 'USD' }); // "$99.99"
|
|
55
|
+
|
|
56
|
+
// Get product description (handles HTML vs plain text)
|
|
57
|
+
const description = getDescriptionContent(product);
|
|
58
|
+
|
|
59
|
+
// Get stock status text
|
|
60
|
+
const stockText = getStockStatus(product.inventory); // "In Stock", "Low Stock", "Out of Stock"
|
|
61
|
+
|
|
62
|
+
// Get effective price (handles sale prices automatically)
|
|
63
|
+
const price = getProductPrice(product); // Returns number: 29.99
|
|
64
|
+
|
|
65
|
+
// Get full price info including sale status
|
|
66
|
+
const priceInfo = getProductPriceInfo(product);
|
|
67
|
+
// { price: 19.99, originalPrice: 29.99, isOnSale: true, discountPercent: 33 }
|
|
68
|
+
|
|
69
|
+
// Calculate cart totals
|
|
70
|
+
const totals = getCartTotals(cart, shippingRate?.price);
|
|
71
|
+
// { subtotal: 59.98, discount: 10, shipping: 5.99, total: 55.97 }
|
|
72
|
+
|
|
73
|
+
// Access cart item details (handles nested structure)
|
|
74
|
+
const itemName = getCartItemName(cartItem); // "Blue T-Shirt - Large"
|
|
75
|
+
const itemImage = getCartItemImage(cartItem); // "https://..."
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
> **⚠️ DO NOT CREATE YOUR OWN UTILITY FILES!** All helper functions above are exported from `brainerce`. Never create `utils/format.ts`, `lib/helpers.ts`, or similar files - use the SDK exports directly.
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## ⚠️ CRITICAL: Payment Integration Required!
|
|
83
|
+
|
|
84
|
+
**Your store will NOT work without payment integration.** The store owner has already configured payment providers (Stripe/PayPal) - you just need to implement the payment page.
|
|
85
|
+
|
|
86
|
+
```typescript
|
|
87
|
+
// On your checkout/payment page, ALWAYS call this first:
|
|
88
|
+
const { hasPayments, providers } = await omni.getPaymentProviders();
|
|
89
|
+
|
|
90
|
+
if (!hasPayments) {
|
|
91
|
+
// Show error - payment is not configured
|
|
92
|
+
return <div>Payment not configured for this store</div>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Show payment forms for available providers
|
|
96
|
+
const stripeProvider = providers.find(p => p.provider === 'stripe');
|
|
97
|
+
const paypalProvider = providers.find(p => p.provider === 'paypal');
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
**See the [Payment Integration](#payment-integration-vibe-coded-sites) section for complete implementation examples.**
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## Quick Start
|
|
105
|
+
|
|
106
|
+
### For Vibe-Coded Sites (Recommended)
|
|
107
|
+
|
|
108
|
+
```typescript
|
|
109
|
+
import { BrainerceClient } from 'brainerce';
|
|
110
|
+
|
|
111
|
+
// Initialize with your Connection ID
|
|
112
|
+
const omni = new BrainerceClient({
|
|
113
|
+
connectionId: 'vc_YOUR_CONNECTION_ID',
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Fetch products
|
|
117
|
+
const { data: products } = await omni.getProducts();
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
## Common Mistakes to Avoid
|
|
123
|
+
|
|
124
|
+
> **AI Agents / Vibe-Coders:** Read this section carefully! These are common misunderstandings.
|
|
125
|
+
|
|
126
|
+
### 1. Guest Checkout - Don't Use createCheckout with Local Cart!
|
|
127
|
+
|
|
128
|
+
**This is the #1 cause of "Cart not found" errors!**
|
|
129
|
+
|
|
130
|
+
```typescript
|
|
131
|
+
// ❌ WRONG - Local cart ID "__local__" doesn't exist on server!
|
|
132
|
+
const cart = await omni.smartGetCart(); // Returns { id: "__local__", ... }
|
|
133
|
+
const checkout = await omni.createCheckout({ cartId: cart.id }); // 💥 ERROR: Cart not found
|
|
134
|
+
|
|
135
|
+
// ✅ CORRECT - Use startGuestCheckout() for guest users
|
|
136
|
+
const result = await omni.startGuestCheckout();
|
|
137
|
+
if (result.tracked) {
|
|
138
|
+
const checkout = await omni.getCheckout(result.checkoutId);
|
|
139
|
+
// Continue with payment flow...
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ✅ ALTERNATIVE - Use submitGuestOrder() for simple checkout without payment UI
|
|
143
|
+
const order = await omni.submitGuestOrder();
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
**Rule of thumb:**
|
|
147
|
+
|
|
148
|
+
- Guest user + Local cart → `startGuestCheckout()` or `submitGuestOrder()`
|
|
149
|
+
- Logged-in user + Server cart → `createCheckout({ cartId })`
|
|
150
|
+
|
|
151
|
+
### 2. ⛔ NEVER Create Local Interfaces - Use SDK Types!
|
|
152
|
+
|
|
153
|
+
**This causes type errors and runtime bugs!**
|
|
154
|
+
|
|
155
|
+
```typescript
|
|
156
|
+
// ❌ WRONG - Don't create your own interfaces!
|
|
157
|
+
interface CartItem {
|
|
158
|
+
id: string;
|
|
159
|
+
name: string; // WRONG - it's item.product.name!
|
|
160
|
+
price: number; // WRONG - prices are strings!
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ❌ WRONG - Don't use 'as unknown as' casting!
|
|
164
|
+
const item = result as unknown as MyLocalType;
|
|
165
|
+
|
|
166
|
+
// ✅ CORRECT - Import ALL types from SDK
|
|
167
|
+
import type {
|
|
168
|
+
Product,
|
|
169
|
+
ProductVariant,
|
|
170
|
+
Cart,
|
|
171
|
+
CartItem,
|
|
172
|
+
Checkout,
|
|
173
|
+
CheckoutLineItem,
|
|
174
|
+
Order,
|
|
175
|
+
OrderItem,
|
|
176
|
+
CustomerProfile,
|
|
177
|
+
CustomerAddress,
|
|
178
|
+
ShippingRate,
|
|
179
|
+
PaymentProvider,
|
|
180
|
+
PaymentIntent,
|
|
181
|
+
PaymentStatus,
|
|
182
|
+
SearchSuggestions,
|
|
183
|
+
ProductSuggestion,
|
|
184
|
+
CategorySuggestion,
|
|
185
|
+
OAuthAuthorizeResponse,
|
|
186
|
+
CustomerOAuthProvider,
|
|
187
|
+
} from 'brainerce';
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
**⚠️ SDK Type Facts - Trust These!**
|
|
191
|
+
|
|
192
|
+
| What | Correct | Wrong |
|
|
193
|
+
| ------------------------ | ----------------------------- | --------------------- |
|
|
194
|
+
| Prices | `string` (use `parseFloat()`) | `number` |
|
|
195
|
+
| Cart item name | `item.product.name` | `item.name` |
|
|
196
|
+
| Order item name | `item.name` | `item.product.name` |
|
|
197
|
+
| Cart item image | `item.product.images[0]` | `item.image` |
|
|
198
|
+
| Order item image | `item.image` | `item.product.images` |
|
|
199
|
+
| Address state/province | `region` | `state` or `province` |
|
|
200
|
+
| OAuth redirect URL | `authorizationUrl` | `url` |
|
|
201
|
+
| OAuth providers response | `{ providers: [...] }` | `[...]` directly |
|
|
202
|
+
|
|
203
|
+
**If you think a type is "wrong", YOU are wrong. Read the SDK types!**
|
|
204
|
+
|
|
205
|
+
### 3. formatPrice Expects Options Object
|
|
206
|
+
|
|
207
|
+
```typescript
|
|
208
|
+
// ❌ WRONG
|
|
209
|
+
formatPrice(amount, 'USD');
|
|
210
|
+
|
|
211
|
+
// ✅ CORRECT
|
|
212
|
+
formatPrice(amount, { currency: 'USD' });
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
### 4. Cart/Checkout vs Order - Different Item Structures!
|
|
216
|
+
|
|
217
|
+
**IMPORTANT:** Cart and Checkout items have NESTED product data. Order items are FLAT.
|
|
218
|
+
|
|
219
|
+
```typescript
|
|
220
|
+
// CartItem and CheckoutLineItem - NESTED product
|
|
221
|
+
cart.items.forEach((item) => {
|
|
222
|
+
console.log(item.product.name); // ✅ Correct for Cart/Checkout
|
|
223
|
+
console.log(item.product.sku);
|
|
224
|
+
console.log(item.product.images);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// OrderItem - FLAT structure
|
|
228
|
+
order.items.forEach((item) => {
|
|
229
|
+
console.log(item.name); // ✅ Correct for Orders
|
|
230
|
+
console.log(item.sku);
|
|
231
|
+
console.log(item.image); // singular, not images
|
|
232
|
+
});
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
| Type | Access Name | Access Image |
|
|
236
|
+
| ------------------ | ------------------- | --------------------- |
|
|
237
|
+
| `CartItem` | `item.product.name` | `item.product.images` |
|
|
238
|
+
| `CheckoutLineItem` | `item.product.name` | `item.product.images` |
|
|
239
|
+
| `OrderItem` | `item.name` | `item.image` |
|
|
240
|
+
|
|
241
|
+
### 5. Payment Status is 'succeeded', not 'completed'
|
|
242
|
+
|
|
243
|
+
```typescript
|
|
244
|
+
// ❌ WRONG
|
|
245
|
+
if (status.status === 'completed')
|
|
246
|
+
|
|
247
|
+
// ✅ CORRECT
|
|
248
|
+
if (status.status === 'succeeded')
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
### 6. ProductSuggestion vs Product - Different Types
|
|
252
|
+
|
|
253
|
+
`getSearchSuggestions()` returns `ProductSuggestion[]`, NOT `Product[]`.
|
|
254
|
+
This is intentional - suggestions are lightweight for autocomplete.
|
|
255
|
+
|
|
256
|
+
```typescript
|
|
257
|
+
// ProductSuggestion has:
|
|
258
|
+
{
|
|
259
|
+
(id, name, slug, image, basePrice, salePrice, type);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Product has many more fields
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
### 7. All Prices Are Strings - Use parseFloat()
|
|
266
|
+
|
|
267
|
+
```typescript
|
|
268
|
+
// ❌ WRONG - assuming number
|
|
269
|
+
const total = item.price * quantity;
|
|
270
|
+
|
|
271
|
+
// ✅ CORRECT - parse first
|
|
272
|
+
const total = parseFloat(item.price) * quantity;
|
|
273
|
+
|
|
274
|
+
// Or use SDK helper
|
|
275
|
+
import { formatPrice } from 'brainerce';
|
|
276
|
+
const display = formatPrice(item.price, { currency: 'USD' });
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
### 8. Variant Attributes Are `Record<string, string>`
|
|
280
|
+
|
|
281
|
+
```typescript
|
|
282
|
+
// Accessing variant attributes:
|
|
283
|
+
const color = variant.attributes?.['Color']; // string
|
|
284
|
+
const size = variant.attributes?.['Size']; // string
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
### 9. Address Uses `region`, NOT `state`
|
|
288
|
+
|
|
289
|
+
```typescript
|
|
290
|
+
// ❌ WRONG
|
|
291
|
+
const address = {
|
|
292
|
+
state: 'NY', // This field doesn't exist!
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
// ✅ CORRECT
|
|
296
|
+
const address: SetShippingAddressDto = {
|
|
297
|
+
firstName: 'John',
|
|
298
|
+
lastName: 'Doe',
|
|
299
|
+
line1: '123 Main St',
|
|
300
|
+
city: 'New York',
|
|
301
|
+
region: 'NY', // Use 'region' for state/province
|
|
302
|
+
postalCode: '10001',
|
|
303
|
+
country: 'US',
|
|
304
|
+
};
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
### 10. OAuth - Use `authorizationUrl`, NOT `url`
|
|
308
|
+
|
|
309
|
+
```typescript
|
|
310
|
+
// ❌ WRONG
|
|
311
|
+
const response = await omni.getOAuthAuthorizeUrl('GOOGLE', { redirectUrl });
|
|
312
|
+
window.location.href = response.url; // 'url' doesn't exist!
|
|
313
|
+
|
|
314
|
+
// ✅ CORRECT
|
|
315
|
+
const response = await omni.getOAuthAuthorizeUrl('GOOGLE', { redirectUrl });
|
|
316
|
+
window.location.href = response.authorizationUrl; // Correct property name
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
### 11. OAuth Provider Type is Exported
|
|
320
|
+
|
|
321
|
+
```typescript
|
|
322
|
+
// ❌ WRONG - creating your own type
|
|
323
|
+
type Provider = 'google' | 'facebook'; // lowercase won't work!
|
|
324
|
+
|
|
325
|
+
// ✅ CORRECT - import from SDK
|
|
326
|
+
import { CustomerOAuthProvider } from 'brainerce';
|
|
327
|
+
// CustomerOAuthProvider = 'GOOGLE' | 'FACEBOOK' | 'GITHUB' (UPPERCASE)
|
|
328
|
+
|
|
329
|
+
const provider: CustomerOAuthProvider = 'GOOGLE';
|
|
330
|
+
await omni.getOAuthAuthorizeUrl(provider, { redirectUrl });
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
### 12. getAvailableOAuthProviders Returns Object, Not Array
|
|
334
|
+
|
|
335
|
+
```typescript
|
|
336
|
+
// ❌ WRONG - expecting array directly
|
|
337
|
+
const providers = await omni.getAvailableOAuthProviders();
|
|
338
|
+
providers.forEach(p => ...); // Error! providers is not an array
|
|
339
|
+
|
|
340
|
+
// ✅ CORRECT - access the providers property
|
|
341
|
+
const response = await omni.getAvailableOAuthProviders();
|
|
342
|
+
response.providers.forEach(p => ...); // response.providers is the array
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
### 13. SDK Uses `null`, Not `undefined`
|
|
346
|
+
|
|
347
|
+
Optional fields in SDK types use `null`, not `undefined`:
|
|
348
|
+
|
|
349
|
+
```typescript
|
|
350
|
+
// SDK types use:
|
|
351
|
+
slug: string | null;
|
|
352
|
+
salePrice: string | null;
|
|
353
|
+
|
|
354
|
+
// So when checking:
|
|
355
|
+
if (product.slug !== null) {
|
|
356
|
+
// ✅ Check for null
|
|
357
|
+
// ...
|
|
358
|
+
}
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
### 14. Cart Has No `total` Field - Use `getCartTotals()` Helper
|
|
362
|
+
|
|
363
|
+
```typescript
|
|
364
|
+
// ❌ WRONG - these fields don't exist on Cart
|
|
365
|
+
const total = cart.total; // ← 'total' doesn't exist!
|
|
366
|
+
const discount = cart.discount; // ← 'discount' doesn't exist! It's 'discountAmount'
|
|
367
|
+
|
|
368
|
+
// ✅ CORRECT - use the helper function (RECOMMENDED)
|
|
369
|
+
import { getCartTotals } from 'brainerce';
|
|
370
|
+
const totals = getCartTotals(cart, shippingPrice);
|
|
371
|
+
// Returns: { subtotal: 59.98, discount: 10, shipping: 5.99, total: 55.97 }
|
|
372
|
+
|
|
373
|
+
// ✅ CORRECT - or calculate manually
|
|
374
|
+
const subtotal = parseFloat(cart.subtotal);
|
|
375
|
+
const discount = parseFloat(cart.discountAmount); // ← Note: 'discountAmount', NOT 'discount'
|
|
376
|
+
const total = subtotal - discount;
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
**Important Notes:**
|
|
380
|
+
|
|
381
|
+
- Cart field is `discountAmount`, NOT `discount`
|
|
382
|
+
- Cart has NO `total` field - use `getCartTotals()` or calculate
|
|
383
|
+
- Checkout DOES have a `total` field, but Cart does not
|
|
384
|
+
- `getCartTotals()` only works with **server Cart** (which has `subtotal` and `discountAmount` fields). It does **NOT** work with **LocalCart** (guest cart from localStorage). For LocalCart, calculate totals manually:
|
|
385
|
+
```typescript
|
|
386
|
+
const subtotal = cart.items.reduce(
|
|
387
|
+
(sum, item) => sum + parseFloat(item.price || '0') * item.quantity,
|
|
388
|
+
0
|
|
389
|
+
);
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
### 15. SearchSuggestions - Products Have `price`, Not `basePrice`
|
|
393
|
+
|
|
394
|
+
```typescript
|
|
395
|
+
// In SearchSuggestions, ProductSuggestion has:
|
|
396
|
+
// - price: effective price (sale price if on sale, otherwise base price)
|
|
397
|
+
// - basePrice: original price
|
|
398
|
+
// - salePrice: sale price if on sale
|
|
399
|
+
|
|
400
|
+
// ✅ Use 'price' for display (it's already the correct price)
|
|
401
|
+
suggestions.products.map(p => (
|
|
402
|
+
<div>{p.name} - {formatPrice(p.price, { currency })}</div>
|
|
403
|
+
));
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
### 16. Forgetting to Clear Cart After Payment
|
|
407
|
+
|
|
408
|
+
**This causes "ghost items" in the cart after successful payment!**
|
|
409
|
+
|
|
410
|
+
```typescript
|
|
411
|
+
// ❌ WRONG - Cart items remain after payment!
|
|
412
|
+
// In your success page:
|
|
413
|
+
export default function SuccessPage() {
|
|
414
|
+
return <div>Thank you for your order!</div>;
|
|
415
|
+
// User goes back to shop → still sees purchased items in cart!
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ✅ CORRECT - Call completeGuestCheckout() on success page
|
|
419
|
+
export default function SuccessPage() {
|
|
420
|
+
const checkoutId = new URLSearchParams(window.location.search).get('checkout_id');
|
|
421
|
+
|
|
422
|
+
useEffect(() => {
|
|
423
|
+
if (checkoutId) {
|
|
424
|
+
// Send order to server AND clear cart
|
|
425
|
+
omni.completeGuestCheckout(checkoutId);
|
|
426
|
+
}
|
|
427
|
+
}, []);
|
|
428
|
+
|
|
429
|
+
return <div>Thank you for your order!</div>;
|
|
430
|
+
}
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
**Why is this needed?**
|
|
434
|
+
|
|
435
|
+
- `completeGuestCheckout()` sends the order to the server AND clears the local cart
|
|
436
|
+
- Without it, the order is never created on the server (payment goes through but no order!)
|
|
437
|
+
- For partial checkout (AliExpress-style), only the purchased items are removed
|
|
438
|
+
- **WARNING:** Do NOT use `handlePaymentSuccess()` - it only clears localStorage and does NOT create the order
|
|
439
|
+
|
|
440
|
+
---
|
|
441
|
+
|
|
442
|
+
## Checkout: Guest vs Logged-In Customer
|
|
443
|
+
|
|
444
|
+
> **⚠️ CRITICAL:** There are TWO different checkout flows. Using the wrong one will cause errors!
|
|
445
|
+
|
|
446
|
+
| Customer Type | Cart Type | With Payment (Stripe) | Without Payment UI |
|
|
447
|
+
| ------------- | ------------------------- | ---------------------- | -------------------- |
|
|
448
|
+
| **Guest** | Local Cart (localStorage) | `startGuestCheckout()` | `submitGuestOrder()` |
|
|
449
|
+
| **Logged In** | Server Cart | `createCheckout()` | `completeCheckout()` |
|
|
450
|
+
|
|
451
|
+
### ❌ COMMON MISTAKE - Don't Do This!
|
|
452
|
+
|
|
453
|
+
```typescript
|
|
454
|
+
// ❌ WRONG - This will FAIL with "Cart not found" error!
|
|
455
|
+
const cart = omni.getLocalCart(); // Returns cart with id: "__local__"
|
|
456
|
+
const checkout = await omni.createCheckout({ cartId: cart.id }); // ERROR!
|
|
457
|
+
|
|
458
|
+
// The "__local__" ID is virtual - it doesn't exist on the server!
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
### ✅ Correct Flow for Guest Checkout with Payment
|
|
462
|
+
|
|
463
|
+
```typescript
|
|
464
|
+
// ✅ CORRECT - Use startGuestCheckout() for guests with local cart
|
|
465
|
+
const result = await omni.startGuestCheckout();
|
|
466
|
+
|
|
467
|
+
if (result.tracked) {
|
|
468
|
+
// Now you have a REAL checkout on the server
|
|
469
|
+
const checkout = await omni.getCheckout(result.checkoutId);
|
|
470
|
+
|
|
471
|
+
// Continue with shipping, payment, etc.
|
|
472
|
+
await omni.setShippingAddress(result.checkoutId, { ... });
|
|
473
|
+
const intent = await omni.createPaymentIntent(result.checkoutId);
|
|
474
|
+
// ... Stripe payment ...
|
|
475
|
+
}
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
### Decision Flow
|
|
479
|
+
|
|
480
|
+
```typescript
|
|
481
|
+
// ALWAYS check this at checkout!
|
|
482
|
+
if (isLoggedIn()) {
|
|
483
|
+
// ✅ Logged-in customer → Server Cart + Checkout flow
|
|
484
|
+
// Orders will be linked to their account
|
|
485
|
+
const order = await completeServerCheckout();
|
|
486
|
+
} else {
|
|
487
|
+
// ✅ Guest → Local Cart + submitGuestOrder
|
|
488
|
+
// Orders are standalone (not linked to any account)
|
|
489
|
+
const order = await omni.submitGuestOrder();
|
|
490
|
+
}
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
### Guest Checkout (for visitors without account)
|
|
494
|
+
|
|
495
|
+
```typescript
|
|
496
|
+
// Cart stored locally - NO API calls until checkout!
|
|
497
|
+
|
|
498
|
+
// Add to local cart (stored in localStorage)
|
|
499
|
+
omni.addToLocalCart({
|
|
500
|
+
productId: products[0].id,
|
|
501
|
+
quantity: 1,
|
|
502
|
+
name: products[0].name,
|
|
503
|
+
price: String(products[0].basePrice),
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
// Set customer info
|
|
507
|
+
omni.setLocalCartCustomer({ email: 'customer@example.com' });
|
|
508
|
+
omni.setLocalCartShippingAddress({
|
|
509
|
+
firstName: 'John',
|
|
510
|
+
lastName: 'Doe',
|
|
511
|
+
line1: '123 Main St',
|
|
512
|
+
city: 'New York',
|
|
513
|
+
postalCode: '10001',
|
|
514
|
+
country: 'US',
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
// Submit order (single API call!)
|
|
518
|
+
const order = await omni.submitGuestOrder();
|
|
519
|
+
console.log('Order created:', order.orderId);
|
|
520
|
+
```
|
|
521
|
+
|
|
522
|
+
### Logged-In Customer Checkout (orders linked to account)
|
|
523
|
+
|
|
524
|
+
```typescript
|
|
525
|
+
// 1. Make sure customer token is set (after login)
|
|
526
|
+
omni.setCustomerToken(authResponse.token);
|
|
527
|
+
|
|
528
|
+
// 2. Create server cart (auto-linked to customer!)
|
|
529
|
+
const cart = await omni.createCart();
|
|
530
|
+
localStorage.setItem('cartId', cart.id);
|
|
531
|
+
|
|
532
|
+
// 3. Add items to server cart
|
|
533
|
+
await omni.addToCart(cart.id, {
|
|
534
|
+
productId: products[0].id,
|
|
535
|
+
quantity: 1,
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
// 4. Create checkout from cart
|
|
539
|
+
const checkout = await omni.createCheckout({ cartId: cart.id });
|
|
540
|
+
|
|
541
|
+
// 5. Set customer info (REQUIRED - email is needed for order!)
|
|
542
|
+
await omni.setCheckoutCustomer(checkout.id, {
|
|
543
|
+
email: 'customer@example.com',
|
|
544
|
+
firstName: 'John',
|
|
545
|
+
lastName: 'Doe',
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
// 6. Set shipping address
|
|
549
|
+
await omni.setShippingAddress(checkout.id, {
|
|
550
|
+
firstName: 'John',
|
|
551
|
+
lastName: 'Doe',
|
|
552
|
+
line1: '123 Main St',
|
|
553
|
+
city: 'New York',
|
|
554
|
+
postalCode: '10001',
|
|
555
|
+
country: 'US',
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
// 7. Get shipping rates and select one
|
|
559
|
+
const rates = await omni.getShippingRates(checkout.id);
|
|
560
|
+
await omni.selectShippingMethod(checkout.id, rates[0].id);
|
|
561
|
+
|
|
562
|
+
// 8. Complete checkout - order is linked to customer!
|
|
563
|
+
const { orderId } = await omni.completeCheckout(checkout.id);
|
|
564
|
+
console.log('Order created:', orderId);
|
|
565
|
+
|
|
566
|
+
// Customer can now see this order in omni.getMyOrders()
|
|
567
|
+
```
|
|
568
|
+
|
|
569
|
+
> **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.
|
|
570
|
+
|
|
571
|
+
---
|
|
572
|
+
|
|
573
|
+
## Two Ways to Handle Cart
|
|
574
|
+
|
|
575
|
+
### Option 1: Local Cart (Guest Users)
|
|
576
|
+
|
|
577
|
+
For guest users, the cart is stored in **localStorage** - exactly like Amazon, Shopify, and other major platforms do. This means:
|
|
578
|
+
|
|
579
|
+
- ✅ No API calls when browsing/adding to cart
|
|
580
|
+
- ✅ Cart persists across page refreshes
|
|
581
|
+
- ✅ Single API call at checkout
|
|
582
|
+
- ✅ No server load for window shoppers
|
|
583
|
+
|
|
584
|
+
```typescript
|
|
585
|
+
// Add product to local cart
|
|
586
|
+
omni.addToLocalCart({ productId: 'prod_123', quantity: 2 });
|
|
587
|
+
|
|
588
|
+
// View cart
|
|
589
|
+
const cart = omni.getLocalCart();
|
|
590
|
+
console.log('Items:', cart.items.length);
|
|
591
|
+
|
|
592
|
+
// Update quantity
|
|
593
|
+
omni.updateLocalCartItem('prod_123', 5);
|
|
594
|
+
|
|
595
|
+
// Remove item
|
|
596
|
+
omni.removeFromLocalCart('prod_123');
|
|
597
|
+
|
|
598
|
+
// At checkout - submit everything in ONE API call
|
|
599
|
+
const order = await omni.submitGuestOrder();
|
|
600
|
+
```
|
|
601
|
+
|
|
602
|
+
### Option 2: Server Cart (Logged-In Customers)
|
|
603
|
+
|
|
604
|
+
For logged-in customers, **you MUST use server-side cart** to link orders to their account:
|
|
605
|
+
|
|
606
|
+
- ✅ Cart syncs across devices
|
|
607
|
+
- ✅ Abandoned cart recovery
|
|
608
|
+
- ✅ Orders linked to customer account
|
|
609
|
+
- ✅ Customer can see orders in "My Orders"
|
|
610
|
+
|
|
611
|
+
```typescript
|
|
612
|
+
// 1. Set customer token (after login)
|
|
613
|
+
omni.setCustomerToken(token);
|
|
614
|
+
|
|
615
|
+
// 2. Create cart (auto-linked to customer)
|
|
616
|
+
const cart = await omni.createCart();
|
|
617
|
+
localStorage.setItem('cartId', cart.id);
|
|
618
|
+
|
|
619
|
+
// 3. Add items
|
|
620
|
+
await omni.addToCart(cart.id, { productId: 'prod_123', quantity: 2 });
|
|
621
|
+
|
|
622
|
+
// 4. At checkout - create checkout and complete
|
|
623
|
+
const checkout = await omni.createCheckout({ cartId: cart.id });
|
|
624
|
+
// ... set shipping address, select shipping method ...
|
|
625
|
+
const { orderId } = await omni.completeCheckout(checkout.id);
|
|
626
|
+
```
|
|
627
|
+
|
|
628
|
+
> **⚠️ CRITICAL:** If you use `submitGuestOrder()` for a logged-in customer, their order will NOT be linked to their account!
|
|
629
|
+
|
|
630
|
+
---
|
|
631
|
+
|
|
632
|
+
## Complete Store Setup
|
|
633
|
+
|
|
634
|
+
### Step 1: Create the Brainerce Client
|
|
635
|
+
|
|
636
|
+
Create a file `lib/brainerce.ts`:
|
|
637
|
+
|
|
638
|
+
```typescript
|
|
639
|
+
import { BrainerceClient } from 'brainerce';
|
|
640
|
+
|
|
641
|
+
export const omni = new BrainerceClient({
|
|
642
|
+
connectionId: 'vc_YOUR_CONNECTION_ID', // Your Connection ID from Brainerce
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
// ----- Guest Cart Helpers (localStorage) -----
|
|
646
|
+
|
|
647
|
+
export function getCartItemCount(): number {
|
|
648
|
+
return omni.getLocalCartItemCount();
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
export function getCart() {
|
|
652
|
+
return omni.getLocalCart();
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// ----- For Registered Users (server cart) -----
|
|
656
|
+
|
|
657
|
+
export function getServerCartId(): string | null {
|
|
658
|
+
if (typeof window === 'undefined') return null;
|
|
659
|
+
return localStorage.getItem('cartId');
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
export function setServerCartId(id: string): void {
|
|
663
|
+
localStorage.setItem('cartId', id);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
export function clearServerCartId(): void {
|
|
667
|
+
localStorage.removeItem('cartId');
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// ----- Customer Token Helpers -----
|
|
671
|
+
|
|
672
|
+
export function setCustomerToken(token: string | null): void {
|
|
673
|
+
if (token) {
|
|
674
|
+
localStorage.setItem('customerToken', token);
|
|
675
|
+
omni.setCustomerToken(token);
|
|
676
|
+
} else {
|
|
677
|
+
localStorage.removeItem('customerToken');
|
|
678
|
+
omni.clearCustomerToken();
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
export function restoreCustomerToken(): string | null {
|
|
683
|
+
const token = localStorage.getItem('customerToken');
|
|
684
|
+
if (token) omni.setCustomerToken(token);
|
|
685
|
+
return token;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
export function isLoggedIn(): boolean {
|
|
689
|
+
return !!localStorage.getItem('customerToken');
|
|
690
|
+
}
|
|
691
|
+
```
|
|
692
|
+
|
|
693
|
+
---
|
|
694
|
+
|
|
695
|
+
## Important: Cart & Checkout Data Structures
|
|
696
|
+
|
|
697
|
+
### Nested Product/Variant Structure
|
|
698
|
+
|
|
699
|
+
Cart and Checkout items use a **nested structure** for product and variant data. This is a common pattern that prevents data duplication and ensures consistency.
|
|
700
|
+
|
|
701
|
+
**Common Mistake:**
|
|
702
|
+
|
|
703
|
+
```typescript
|
|
704
|
+
// WRONG - product name is NOT at top level
|
|
705
|
+
const name = item.name; // undefined!
|
|
706
|
+
const sku = item.sku; // undefined!
|
|
707
|
+
```
|
|
708
|
+
|
|
709
|
+
**Correct Access Pattern:**
|
|
710
|
+
|
|
711
|
+
```typescript
|
|
712
|
+
// CORRECT - access via nested objects
|
|
713
|
+
const name = item.product.name;
|
|
714
|
+
const sku = item.product.sku;
|
|
715
|
+
const variantName = item.variant?.name;
|
|
716
|
+
const variantSku = item.variant?.sku;
|
|
717
|
+
```
|
|
718
|
+
|
|
719
|
+
### Field Mapping Reference
|
|
720
|
+
|
|
721
|
+
| What You Want | CartItem | CheckoutLineItem |
|
|
722
|
+
| -------------- | ------------------------- | ------------------------- |
|
|
723
|
+
| Product Name | `item.product.name` | `item.product.name` |
|
|
724
|
+
| Product SKU | `item.product.sku` | `item.product.sku` |
|
|
725
|
+
| Product ID | `item.productId` | `item.productId` |
|
|
726
|
+
| Product Images | `item.product.images` | `item.product.images` |
|
|
727
|
+
| Variant Name | `item.variant?.name` | `item.variant?.name` |
|
|
728
|
+
| Variant SKU | `item.variant?.sku` | `item.variant?.sku` |
|
|
729
|
+
| Variant ID | `item.variantId` | `item.variantId` |
|
|
730
|
+
| Unit Price | `item.unitPrice` (string) | `item.unitPrice` (string) |
|
|
731
|
+
| Quantity | `item.quantity` | `item.quantity` |
|
|
732
|
+
|
|
733
|
+
### Price Fields Are Strings
|
|
734
|
+
|
|
735
|
+
All monetary values in Cart and Checkout are returned as **strings** (e.g., `"29.99"`) to preserve decimal precision across different systems. Use `parseFloat()` or the `formatPrice()` helper:
|
|
736
|
+
|
|
737
|
+
```typescript
|
|
738
|
+
// Monetary fields that are strings:
|
|
739
|
+
// - CartItem: unitPrice, discountAmount
|
|
740
|
+
// - Cart: subtotal, discountAmount
|
|
741
|
+
// - CheckoutLineItem: unitPrice, discountAmount
|
|
742
|
+
// - Checkout: subtotal, discountAmount, shippingAmount, taxAmount, total
|
|
743
|
+
// - ShippingRate: price
|
|
744
|
+
|
|
745
|
+
import { formatPrice } from 'brainerce';
|
|
746
|
+
|
|
747
|
+
// Option 1: Using formatPrice helper (recommended)
|
|
748
|
+
const cart = await omni.getCart(cartId);
|
|
749
|
+
const total = formatPrice(cart.subtotal); // "$59.98"
|
|
750
|
+
const totalNum = formatPrice(cart.subtotal, { asNumber: true }); // 59.98
|
|
751
|
+
|
|
752
|
+
// Option 2: Manual parseFloat
|
|
753
|
+
const subtotal = parseFloat(cart.subtotal);
|
|
754
|
+
const discount = parseFloat(cart.discountAmount);
|
|
755
|
+
const total = subtotal - discount;
|
|
756
|
+
|
|
757
|
+
// Line item total
|
|
758
|
+
cart.items.forEach((item) => {
|
|
759
|
+
const lineTotal = parseFloat(item.unitPrice) * item.quantity;
|
|
760
|
+
console.log(`${item.product.name}: $${lineTotal.toFixed(2)}`);
|
|
761
|
+
});
|
|
762
|
+
```
|
|
763
|
+
|
|
764
|
+
### Complete Cart Item Display Example
|
|
765
|
+
|
|
766
|
+
```typescript
|
|
767
|
+
import type { CartItem } from 'brainerce';
|
|
768
|
+
import { formatPrice } from 'brainerce';
|
|
769
|
+
|
|
770
|
+
function CartItemRow({ item }: { item: CartItem }) {
|
|
771
|
+
// Access nested product data
|
|
772
|
+
const productName = item.product.name;
|
|
773
|
+
const productSku = item.product.sku;
|
|
774
|
+
const productImage = item.product.images?.[0]?.url;
|
|
775
|
+
|
|
776
|
+
// Access nested variant data (if exists)
|
|
777
|
+
const variantName = item.variant?.name;
|
|
778
|
+
const displayName = variantName ? `${productName} - ${variantName}` : productName;
|
|
779
|
+
|
|
780
|
+
// Format price using helper
|
|
781
|
+
const unitPrice = formatPrice(item.unitPrice);
|
|
782
|
+
const lineTotal = formatPrice(item.unitPrice, { asNumber: true }) * item.quantity;
|
|
783
|
+
|
|
784
|
+
return (
|
|
785
|
+
<div className="flex items-center gap-4">
|
|
786
|
+
<img src={productImage} alt={displayName} className="w-16 h-16 object-cover" />
|
|
787
|
+
<div className="flex-1">
|
|
788
|
+
<h3 className="font-medium">{displayName}</h3>
|
|
789
|
+
<p className="text-sm text-gray-500">SKU: {item.variant?.sku || productSku}</p>
|
|
790
|
+
</div>
|
|
791
|
+
<span className="text-gray-600">Qty: {item.quantity}</span>
|
|
792
|
+
<span className="font-medium">${lineTotal.toFixed(2)}</span>
|
|
793
|
+
</div>
|
|
794
|
+
);
|
|
795
|
+
}
|
|
796
|
+
```
|
|
797
|
+
|
|
798
|
+
---
|
|
799
|
+
|
|
800
|
+
## API Reference
|
|
801
|
+
|
|
802
|
+
### Products
|
|
803
|
+
|
|
804
|
+
#### Get Products (with pagination)
|
|
805
|
+
|
|
806
|
+
```typescript
|
|
807
|
+
import { omni } from '@/lib/brainerce';
|
|
808
|
+
import type { Product, PaginatedResponse } from 'brainerce';
|
|
809
|
+
|
|
810
|
+
const response: PaginatedResponse<Product> = await omni.getProducts({
|
|
811
|
+
page: 1,
|
|
812
|
+
limit: 12,
|
|
813
|
+
search: 'shirt', // Optional: search by name
|
|
814
|
+
status: 'active', // Optional: 'active' | 'draft' | 'archived'
|
|
815
|
+
type: 'SIMPLE', // Optional: 'SIMPLE' | 'VARIABLE'
|
|
816
|
+
sortBy: 'createdAt', // Optional: 'name' | 'createdAt' | 'updatedAt' | 'basePrice'
|
|
817
|
+
sortOrder: 'desc', // Optional: 'asc' | 'desc'
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
console.log(response.data); // Product[]
|
|
821
|
+
console.log(response.meta.total); // Total number of products
|
|
822
|
+
console.log(response.meta.totalPages); // Total pages
|
|
823
|
+
```
|
|
824
|
+
|
|
825
|
+
#### Get Single Product
|
|
826
|
+
|
|
827
|
+
```typescript
|
|
828
|
+
const product: Product = await omni.getProduct('product_id');
|
|
829
|
+
|
|
830
|
+
console.log(product.name);
|
|
831
|
+
console.log(product.basePrice);
|
|
832
|
+
console.log(product.salePrice); // null if no sale
|
|
833
|
+
console.log(product.images); // ProductImage[]
|
|
834
|
+
console.log(product.variants); // ProductVariant[] (for VARIABLE products)
|
|
835
|
+
console.log(product.inventory); // { total, reserved, available }
|
|
836
|
+
```
|
|
837
|
+
|
|
838
|
+
#### Search Suggestions (Autocomplete)
|
|
839
|
+
|
|
840
|
+
Get search suggestions for building autocomplete/search-as-you-type UI:
|
|
841
|
+
|
|
842
|
+
```typescript
|
|
843
|
+
import type { SearchSuggestions } from 'brainerce';
|
|
844
|
+
|
|
845
|
+
// Basic autocomplete
|
|
846
|
+
const suggestions: SearchSuggestions = await omni.getSearchSuggestions('shirt');
|
|
847
|
+
|
|
848
|
+
console.log(suggestions.products);
|
|
849
|
+
// [{ id, name, image, basePrice, salePrice, type }]
|
|
850
|
+
|
|
851
|
+
console.log(suggestions.categories);
|
|
852
|
+
// [{ id, name, productCount }]
|
|
853
|
+
|
|
854
|
+
// With custom limit (default: 5, max: 10)
|
|
855
|
+
const suggestions = await omni.getSearchSuggestions('dress', 3);
|
|
856
|
+
```
|
|
857
|
+
|
|
858
|
+
**Search covers:** name, sku, description, categories, tags, and brands.
|
|
859
|
+
|
|
860
|
+
**Example: Search Input with Suggestions**
|
|
861
|
+
|
|
862
|
+
```typescript
|
|
863
|
+
function SearchInput() {
|
|
864
|
+
const [query, setQuery] = useState('');
|
|
865
|
+
const [suggestions, setSuggestions] = useState<SearchSuggestions | null>(null);
|
|
866
|
+
|
|
867
|
+
// Debounce search requests
|
|
868
|
+
useEffect(() => {
|
|
869
|
+
if (query.length < 2) {
|
|
870
|
+
setSuggestions(null);
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
const timer = setTimeout(async () => {
|
|
875
|
+
const results = await omni.getSearchSuggestions(query, 5);
|
|
876
|
+
setSuggestions(results);
|
|
877
|
+
}, 300);
|
|
878
|
+
|
|
879
|
+
return () => clearTimeout(timer);
|
|
880
|
+
}, [query]);
|
|
881
|
+
|
|
882
|
+
return (
|
|
883
|
+
<div>
|
|
884
|
+
<input
|
|
885
|
+
value={query}
|
|
886
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
887
|
+
placeholder="Search products..."
|
|
888
|
+
/>
|
|
889
|
+
{suggestions && (
|
|
890
|
+
<div className="suggestions">
|
|
891
|
+
{suggestions.products.map((product) => (
|
|
892
|
+
<a key={product.id} href={`/products/${product.slug}`}>
|
|
893
|
+
<img src={product.image || '/placeholder.png'} alt={product.name} />
|
|
894
|
+
<span>{product.name}</span>
|
|
895
|
+
<span>${product.basePrice}</span>
|
|
896
|
+
</a>
|
|
897
|
+
))}
|
|
898
|
+
{suggestions.categories.map((category) => (
|
|
899
|
+
<a key={category.id} href={`/category/${category.id}`}>
|
|
900
|
+
{category.name} ({category.productCount} products)
|
|
901
|
+
</a>
|
|
902
|
+
))}
|
|
903
|
+
</div>
|
|
904
|
+
)}
|
|
905
|
+
</div>
|
|
906
|
+
);
|
|
907
|
+
}
|
|
908
|
+
```
|
|
909
|
+
|
|
910
|
+
#### Product Type Definition
|
|
911
|
+
|
|
912
|
+
```typescript
|
|
913
|
+
interface Product {
|
|
914
|
+
id: string;
|
|
915
|
+
name: string;
|
|
916
|
+
description?: string | null;
|
|
917
|
+
descriptionFormat?: 'text' | 'html' | 'markdown'; // Format of description content
|
|
918
|
+
sku: string;
|
|
919
|
+
basePrice: number;
|
|
920
|
+
salePrice?: number | null;
|
|
921
|
+
status: 'active' | 'draft' | 'archived';
|
|
922
|
+
type: 'SIMPLE' | 'VARIABLE';
|
|
923
|
+
images?: ProductImage[];
|
|
924
|
+
inventory?: InventoryInfo | null;
|
|
925
|
+
variants?: ProductVariant[];
|
|
926
|
+
categories?: string[];
|
|
927
|
+
tags?: string[];
|
|
928
|
+
createdAt: string;
|
|
929
|
+
updatedAt: string;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
interface ProductImage {
|
|
933
|
+
url: string;
|
|
934
|
+
position?: number;
|
|
935
|
+
isMain?: boolean;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
interface ProductVariant {
|
|
939
|
+
id: string;
|
|
940
|
+
sku?: string | null;
|
|
941
|
+
name?: string | null;
|
|
942
|
+
price?: number | null;
|
|
943
|
+
salePrice?: number | null;
|
|
944
|
+
attributes?: Record<string, string>;
|
|
945
|
+
inventory?: InventoryInfo | null;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
interface InventoryInfo {
|
|
949
|
+
total: number;
|
|
950
|
+
reserved: number;
|
|
951
|
+
available: number;
|
|
952
|
+
trackingMode?: 'TRACKED' | 'UNLIMITED' | 'DISABLED';
|
|
953
|
+
inStock: boolean; // Pre-calculated - use this for display!
|
|
954
|
+
canPurchase: boolean; // Pre-calculated - use this for add-to-cart
|
|
955
|
+
}
|
|
956
|
+
```
|
|
957
|
+
|
|
958
|
+
#### Product Metafields (Custom Fields)
|
|
959
|
+
|
|
960
|
+
Products can have custom fields (metafields) defined by the store owner, such as "Material", "Care Instructions", or "Warranty".
|
|
961
|
+
|
|
962
|
+
```typescript
|
|
963
|
+
import {
|
|
964
|
+
getProductMetafield,
|
|
965
|
+
getProductMetafieldValue,
|
|
966
|
+
getProductMetafieldsByType,
|
|
967
|
+
} from 'brainerce';
|
|
968
|
+
|
|
969
|
+
const product = await omni.getProductBySlug('blue-shirt');
|
|
970
|
+
|
|
971
|
+
// Access all metafields
|
|
972
|
+
product.metafields?.forEach((field) => {
|
|
973
|
+
console.log(`${field.definitionName}: ${field.value}`);
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
// Get a specific metafield by key
|
|
977
|
+
const material = getProductMetafieldValue(product, 'material'); // auto-parsed (string | number | boolean | null)
|
|
978
|
+
const careField = getProductMetafield(product, 'care_instructions'); // full ProductMetafield object
|
|
979
|
+
|
|
980
|
+
// Filter metafields by type
|
|
981
|
+
const textFields = getProductMetafieldsByType(product, 'TEXT');
|
|
982
|
+
|
|
983
|
+
// Fetch metafield definitions (schema) to build dynamic UI
|
|
984
|
+
const { definitions } = await omni.getPublicMetafieldDefinitions();
|
|
985
|
+
definitions.forEach((def) => {
|
|
986
|
+
console.log(`${def.name} (${def.key}): ${def.type}, required: ${def.required}`);
|
|
987
|
+
});
|
|
988
|
+
```
|
|
989
|
+
|
|
990
|
+
> **Note:** `metafields` may be empty if the store hasn't defined custom fields. Always use optional chaining (`product.metafields?.forEach`).
|
|
991
|
+
|
|
992
|
+
#### Displaying Price Range for Variable Products
|
|
993
|
+
|
|
994
|
+
For products with `type: 'VARIABLE'` and multiple variants with different prices, display a **price range** instead of a single price:
|
|
995
|
+
|
|
996
|
+
```typescript
|
|
997
|
+
// Helper function to get price range from variants
|
|
998
|
+
function getPriceRange(product: Product): { min: number; max: number } | null {
|
|
999
|
+
if (product.type !== 'VARIABLE' || !product.variants?.length) {
|
|
1000
|
+
return null;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
const prices = product.variants
|
|
1004
|
+
.map(v => v.price ?? product.basePrice)
|
|
1005
|
+
.filter((p): p is number => p !== null);
|
|
1006
|
+
|
|
1007
|
+
if (prices.length === 0) return null;
|
|
1008
|
+
|
|
1009
|
+
const min = Math.min(...prices);
|
|
1010
|
+
const max = Math.max(...prices);
|
|
1011
|
+
|
|
1012
|
+
// Return null if all variants have the same price
|
|
1013
|
+
return min !== max ? { min, max } : null;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
// Usage in component
|
|
1017
|
+
function ProductPrice({ product }: { product: Product }) {
|
|
1018
|
+
const priceRange = getPriceRange(product);
|
|
1019
|
+
|
|
1020
|
+
if (priceRange) {
|
|
1021
|
+
// Variable product with different variant prices - show range
|
|
1022
|
+
return <span>${priceRange.min} - ${priceRange.max}</span>;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
// Simple product or all variants same price - show single price
|
|
1026
|
+
return product.salePrice ? (
|
|
1027
|
+
<>
|
|
1028
|
+
<span className="text-red-600">${product.salePrice}</span>
|
|
1029
|
+
<span className="line-through text-gray-400 ml-2">${product.basePrice}</span>
|
|
1030
|
+
</>
|
|
1031
|
+
) : (
|
|
1032
|
+
<span>${product.basePrice}</span>
|
|
1033
|
+
);
|
|
1034
|
+
}
|
|
1035
|
+
```
|
|
1036
|
+
|
|
1037
|
+
**When to show price range:**
|
|
1038
|
+
|
|
1039
|
+
- Product `type` is `'VARIABLE'`
|
|
1040
|
+
- Has 2+ variants with **different** prices
|
|
1041
|
+
- Example: T-shirt sizes S/M/L at $29, XL/XXL at $34 → Display "$29 - $34"
|
|
1042
|
+
|
|
1043
|
+
**When to show single price:**
|
|
1044
|
+
|
|
1045
|
+
- Product `type` is `'SIMPLE'`
|
|
1046
|
+
- Variable product where all variants have the same price
|
|
1047
|
+
|
|
1048
|
+
#### Rendering Product Descriptions
|
|
1049
|
+
|
|
1050
|
+
> **CRITICAL**: Product descriptions from Shopify/WooCommerce contain HTML tags. If you render them as plain text, users will see raw `<p>`, `<ul>`, `<li>` tags instead of formatted content!
|
|
1051
|
+
|
|
1052
|
+
Use the SDK helper functions to handle this automatically:
|
|
1053
|
+
|
|
1054
|
+
```tsx
|
|
1055
|
+
import { isHtmlDescription, getDescriptionContent } from 'brainerce';
|
|
1056
|
+
|
|
1057
|
+
// Option 1: Using isHtmlDescription helper (recommended)
|
|
1058
|
+
function ProductDescription({ product }: { product: Product }) {
|
|
1059
|
+
if (!product.description) return null;
|
|
1060
|
+
|
|
1061
|
+
if (isHtmlDescription(product)) {
|
|
1062
|
+
// HTML from Shopify/WooCommerce - MUST use dangerouslySetInnerHTML
|
|
1063
|
+
return <div dangerouslySetInnerHTML={{ __html: product.description }} />;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
// Plain text - render normally
|
|
1067
|
+
return <p>{product.description}</p>;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
// Option 2: Using getDescriptionContent helper
|
|
1071
|
+
function ProductDescription({ product }: { product: Product }) {
|
|
1072
|
+
const content = getDescriptionContent(product);
|
|
1073
|
+
if (!content) return null;
|
|
1074
|
+
|
|
1075
|
+
if ('html' in content) {
|
|
1076
|
+
return <div dangerouslySetInnerHTML={{ __html: content.html }} />;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
return <p>{content.text}</p>;
|
|
1080
|
+
}
|
|
1081
|
+
```
|
|
1082
|
+
|
|
1083
|
+
| Source Platform | descriptionFormat | Rendering |
|
|
1084
|
+
| --------------- | ----------------- | ----------------------------- |
|
|
1085
|
+
| Shopify | `'html'` | Use `dangerouslySetInnerHTML` |
|
|
1086
|
+
| WooCommerce | `'html'` | Use `dangerouslySetInnerHTML` |
|
|
1087
|
+
| TikTok | `'text'` | Render as plain text |
|
|
1088
|
+
| Manual entry | `'text'` | Render as plain text |
|
|
1089
|
+
|
|
1090
|
+
**Common Mistake** - DO NOT do this:
|
|
1091
|
+
|
|
1092
|
+
```tsx
|
|
1093
|
+
// WRONG - HTML will show as raw tags like <p>Hello</p>
|
|
1094
|
+
<p>{product.description}</p>
|
|
1095
|
+
```
|
|
1096
|
+
|
|
1097
|
+
---
|
|
1098
|
+
|
|
1099
|
+
### Local Cart (Guest Users) - RECOMMENDED
|
|
1100
|
+
|
|
1101
|
+
The local cart stores everything in **localStorage** until checkout. This is the recommended approach for most storefronts.
|
|
1102
|
+
|
|
1103
|
+
#### Add to Local Cart
|
|
1104
|
+
|
|
1105
|
+
```typescript
|
|
1106
|
+
// Add item with product info (for display)
|
|
1107
|
+
omni.addToLocalCart({
|
|
1108
|
+
productId: 'prod_123',
|
|
1109
|
+
variantId: 'var_456', // Optional: for products with variants
|
|
1110
|
+
quantity: 2,
|
|
1111
|
+
name: 'Cool T-Shirt', // Optional: for cart display
|
|
1112
|
+
price: '29.99', // Optional: for cart display
|
|
1113
|
+
image: 'https://...', // Optional: for cart display
|
|
1114
|
+
});
|
|
1115
|
+
```
|
|
1116
|
+
|
|
1117
|
+
#### Get Local Cart
|
|
1118
|
+
|
|
1119
|
+
```typescript
|
|
1120
|
+
const cart = omni.getLocalCart();
|
|
1121
|
+
|
|
1122
|
+
console.log(cart.items); // Array of cart items
|
|
1123
|
+
console.log(cart.customer); // Customer info (if set)
|
|
1124
|
+
console.log(cart.shippingAddress); // Shipping address (if set)
|
|
1125
|
+
console.log(cart.couponCode); // Applied coupon (if any)
|
|
1126
|
+
```
|
|
1127
|
+
|
|
1128
|
+
#### Update Item Quantity
|
|
1129
|
+
|
|
1130
|
+
```typescript
|
|
1131
|
+
// Set quantity to 5
|
|
1132
|
+
omni.updateLocalCartItem('prod_123', 5);
|
|
1133
|
+
|
|
1134
|
+
// For variant products
|
|
1135
|
+
omni.updateLocalCartItem('prod_123', 3, 'var_456');
|
|
1136
|
+
|
|
1137
|
+
// Set to 0 to remove
|
|
1138
|
+
omni.updateLocalCartItem('prod_123', 0);
|
|
1139
|
+
```
|
|
1140
|
+
|
|
1141
|
+
#### Remove Item
|
|
1142
|
+
|
|
1143
|
+
```typescript
|
|
1144
|
+
omni.removeFromLocalCart('prod_123');
|
|
1145
|
+
omni.removeFromLocalCart('prod_123', 'var_456'); // With variant
|
|
1146
|
+
```
|
|
1147
|
+
|
|
1148
|
+
#### Clear Cart
|
|
1149
|
+
|
|
1150
|
+
```typescript
|
|
1151
|
+
omni.clearLocalCart();
|
|
1152
|
+
```
|
|
1153
|
+
|
|
1154
|
+
#### Set Customer Info
|
|
1155
|
+
|
|
1156
|
+
```typescript
|
|
1157
|
+
omni.setLocalCartCustomer({
|
|
1158
|
+
email: 'customer@example.com', // Required
|
|
1159
|
+
firstName: 'John', // Optional
|
|
1160
|
+
lastName: 'Doe', // Optional
|
|
1161
|
+
phone: '+1234567890', // Optional
|
|
1162
|
+
});
|
|
1163
|
+
```
|
|
1164
|
+
|
|
1165
|
+
#### Set Shipping Address
|
|
1166
|
+
|
|
1167
|
+
```typescript
|
|
1168
|
+
omni.setLocalCartShippingAddress({
|
|
1169
|
+
firstName: 'John',
|
|
1170
|
+
lastName: 'Doe',
|
|
1171
|
+
line1: '123 Main St',
|
|
1172
|
+
line2: 'Apt 4B', // Optional
|
|
1173
|
+
city: 'New York',
|
|
1174
|
+
region: 'NY', // Optional: State/Province
|
|
1175
|
+
postalCode: '10001',
|
|
1176
|
+
country: 'US',
|
|
1177
|
+
phone: '+1234567890', // Optional
|
|
1178
|
+
});
|
|
1179
|
+
```
|
|
1180
|
+
|
|
1181
|
+
#### Set Billing Address (Optional)
|
|
1182
|
+
|
|
1183
|
+
```typescript
|
|
1184
|
+
omni.setLocalCartBillingAddress({
|
|
1185
|
+
firstName: 'John',
|
|
1186
|
+
lastName: 'Doe',
|
|
1187
|
+
line1: '456 Business Ave',
|
|
1188
|
+
city: 'New York',
|
|
1189
|
+
postalCode: '10002',
|
|
1190
|
+
country: 'US',
|
|
1191
|
+
});
|
|
1192
|
+
```
|
|
1193
|
+
|
|
1194
|
+
#### Apply Coupon
|
|
1195
|
+
|
|
1196
|
+
```typescript
|
|
1197
|
+
omni.setLocalCartCoupon('SAVE20');
|
|
1198
|
+
|
|
1199
|
+
// Remove coupon
|
|
1200
|
+
omni.setLocalCartCoupon(undefined);
|
|
1201
|
+
```
|
|
1202
|
+
|
|
1203
|
+
#### Get Cart Item Count
|
|
1204
|
+
|
|
1205
|
+
```typescript
|
|
1206
|
+
const count = omni.getLocalCartItemCount();
|
|
1207
|
+
console.log(`${count} items in cart`);
|
|
1208
|
+
```
|
|
1209
|
+
|
|
1210
|
+
#### Local Cart Type Definition
|
|
1211
|
+
|
|
1212
|
+
```typescript
|
|
1213
|
+
interface LocalCart {
|
|
1214
|
+
items: LocalCartItem[];
|
|
1215
|
+
couponCode?: string;
|
|
1216
|
+
customer?: {
|
|
1217
|
+
email: string;
|
|
1218
|
+
firstName?: string;
|
|
1219
|
+
lastName?: string;
|
|
1220
|
+
phone?: string;
|
|
1221
|
+
};
|
|
1222
|
+
shippingAddress?: {
|
|
1223
|
+
firstName: string;
|
|
1224
|
+
lastName: string;
|
|
1225
|
+
line1: string;
|
|
1226
|
+
line2?: string;
|
|
1227
|
+
city: string;
|
|
1228
|
+
region?: string;
|
|
1229
|
+
postalCode: string;
|
|
1230
|
+
country: string;
|
|
1231
|
+
phone?: string;
|
|
1232
|
+
};
|
|
1233
|
+
billingAddress?: {
|
|
1234
|
+
/* same as shipping */
|
|
1235
|
+
};
|
|
1236
|
+
notes?: string;
|
|
1237
|
+
updatedAt: string;
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
interface LocalCartItem {
|
|
1241
|
+
productId: string;
|
|
1242
|
+
variantId?: string;
|
|
1243
|
+
quantity: number;
|
|
1244
|
+
name?: string;
|
|
1245
|
+
sku?: string;
|
|
1246
|
+
price?: string;
|
|
1247
|
+
image?: string;
|
|
1248
|
+
addedAt: string;
|
|
1249
|
+
}
|
|
1250
|
+
```
|
|
1251
|
+
|
|
1252
|
+
---
|
|
1253
|
+
|
|
1254
|
+
### Guest Checkout (Submit Order)
|
|
1255
|
+
|
|
1256
|
+
Submit the local cart as an order with a **single API call**:
|
|
1257
|
+
|
|
1258
|
+
```typescript
|
|
1259
|
+
// Make sure cart has items, customer email, and shipping address
|
|
1260
|
+
const order = await omni.submitGuestOrder();
|
|
1261
|
+
|
|
1262
|
+
console.log(order.orderId); // 'order_abc123...'
|
|
1263
|
+
console.log(order.orderNumber); // 'ORD-12345'
|
|
1264
|
+
console.log(order.status); // 'pending'
|
|
1265
|
+
console.log(order.total); // 59.98
|
|
1266
|
+
console.log(order.message); // 'Order created successfully'
|
|
1267
|
+
|
|
1268
|
+
// Cart is automatically cleared after successful order
|
|
1269
|
+
```
|
|
1270
|
+
|
|
1271
|
+
> **🔄 Automatic Tracking:** If "Track Guest Checkouts" is enabled in your connection settings (Brainerce Admin), `submitGuestOrder()` will automatically create a tracked checkout session before placing the order. This allows you to see abandoned carts and checkout sessions in your admin dashboard - **no code changes needed!**
|
|
1272
|
+
|
|
1273
|
+
#### Keep Cart After Order
|
|
1274
|
+
|
|
1275
|
+
```typescript
|
|
1276
|
+
// If you want to keep the cart data (e.g., for order review page)
|
|
1277
|
+
const order = await omni.submitGuestOrder({ clearCartOnSuccess: false });
|
|
1278
|
+
```
|
|
1279
|
+
|
|
1280
|
+
#### Create Order with Custom Data
|
|
1281
|
+
|
|
1282
|
+
If you manage cart state yourself instead of using local cart:
|
|
1283
|
+
|
|
1284
|
+
```typescript
|
|
1285
|
+
const order = await omni.createGuestOrder({
|
|
1286
|
+
items: [
|
|
1287
|
+
{ productId: 'prod_123', quantity: 2 },
|
|
1288
|
+
{ productId: 'prod_456', variantId: 'var_789', quantity: 1 },
|
|
1289
|
+
],
|
|
1290
|
+
customer: {
|
|
1291
|
+
email: 'customer@example.com',
|
|
1292
|
+
firstName: 'John',
|
|
1293
|
+
lastName: 'Doe',
|
|
1294
|
+
},
|
|
1295
|
+
shippingAddress: {
|
|
1296
|
+
firstName: 'John',
|
|
1297
|
+
lastName: 'Doe',
|
|
1298
|
+
line1: '123 Main St',
|
|
1299
|
+
city: 'New York',
|
|
1300
|
+
postalCode: '10001',
|
|
1301
|
+
country: 'US',
|
|
1302
|
+
},
|
|
1303
|
+
couponCode: 'SAVE20', // Optional
|
|
1304
|
+
notes: 'Please gift wrap', // Optional
|
|
1305
|
+
});
|
|
1306
|
+
```
|
|
1307
|
+
|
|
1308
|
+
#### Guest Order Response Type
|
|
1309
|
+
|
|
1310
|
+
```typescript
|
|
1311
|
+
interface GuestOrderResponse {
|
|
1312
|
+
orderId: string;
|
|
1313
|
+
orderNumber: string;
|
|
1314
|
+
status: string;
|
|
1315
|
+
total: number;
|
|
1316
|
+
message: string;
|
|
1317
|
+
}
|
|
1318
|
+
```
|
|
1319
|
+
|
|
1320
|
+
---
|
|
1321
|
+
|
|
1322
|
+
### Tracked Guest Checkout (Automatic)
|
|
1323
|
+
|
|
1324
|
+
> **Note:** As of SDK v0.7.1, `submitGuestOrder()` automatically handles tracking. You don't need to use these methods unless you want explicit control over the checkout flow.
|
|
1325
|
+
|
|
1326
|
+
When **"Track Guest Checkouts"** is enabled in your connection settings, checkout sessions are automatically created on the server, allowing:
|
|
1327
|
+
|
|
1328
|
+
- Visibility of checkout sessions in admin dashboard
|
|
1329
|
+
- Abandoned cart tracking
|
|
1330
|
+
- Future: abandoned cart recovery emails
|
|
1331
|
+
|
|
1332
|
+
#### How to Enable
|
|
1333
|
+
|
|
1334
|
+
1. Go to Brainerce Admin → Integrations → Vibe-Coded Sites
|
|
1335
|
+
2. Click on your connection → Settings
|
|
1336
|
+
3. Enable "Track Guest Checkouts"
|
|
1337
|
+
4. Save - that's it! No code changes needed.
|
|
1338
|
+
|
|
1339
|
+
#### Advanced: Manual Tracking Control
|
|
1340
|
+
|
|
1341
|
+
If you need explicit control over the tracking flow (e.g., to track checkout steps before the user places an order):
|
|
1342
|
+
|
|
1343
|
+
```typescript
|
|
1344
|
+
// 1. Start tracked checkout (sends cart items to server)
|
|
1345
|
+
const checkout = await omni.startGuestCheckout();
|
|
1346
|
+
|
|
1347
|
+
if (checkout.tracked) {
|
|
1348
|
+
// 2. Update with shipping address
|
|
1349
|
+
await omni.updateGuestCheckoutAddress(checkout.checkoutId, {
|
|
1350
|
+
shippingAddress: {
|
|
1351
|
+
firstName: 'John',
|
|
1352
|
+
lastName: 'Doe',
|
|
1353
|
+
line1: '123 Main St',
|
|
1354
|
+
city: 'New York',
|
|
1355
|
+
postalCode: '10001',
|
|
1356
|
+
country: 'US',
|
|
1357
|
+
},
|
|
1358
|
+
});
|
|
1359
|
+
|
|
1360
|
+
// 3. Complete the checkout
|
|
1361
|
+
const order = await omni.completeGuestCheckout(checkout.checkoutId);
|
|
1362
|
+
console.log('Order created:', order.orderId);
|
|
1363
|
+
} else {
|
|
1364
|
+
// Fallback to regular guest checkout
|
|
1365
|
+
const order = await omni.submitGuestOrder();
|
|
1366
|
+
}
|
|
1367
|
+
```
|
|
1368
|
+
|
|
1369
|
+
#### Response Types
|
|
1370
|
+
|
|
1371
|
+
```typescript
|
|
1372
|
+
type GuestCheckoutStartResponse =
|
|
1373
|
+
| {
|
|
1374
|
+
tracked: true;
|
|
1375
|
+
checkoutId: string;
|
|
1376
|
+
cartId: string;
|
|
1377
|
+
message: string;
|
|
1378
|
+
}
|
|
1379
|
+
| {
|
|
1380
|
+
tracked: false;
|
|
1381
|
+
message: string;
|
|
1382
|
+
};
|
|
1383
|
+
```
|
|
1384
|
+
|
|
1385
|
+
---
|
|
1386
|
+
|
|
1387
|
+
### Server Cart (Registered Users)
|
|
1388
|
+
|
|
1389
|
+
For logged-in customers who want cart sync across devices.
|
|
1390
|
+
|
|
1391
|
+
#### Create Cart
|
|
1392
|
+
|
|
1393
|
+
```typescript
|
|
1394
|
+
const cart = await omni.createCart();
|
|
1395
|
+
setServerCartId(cart.id); // Save to localStorage
|
|
1396
|
+
```
|
|
1397
|
+
|
|
1398
|
+
#### Get Cart
|
|
1399
|
+
|
|
1400
|
+
```typescript
|
|
1401
|
+
const cartId = getCartId();
|
|
1402
|
+
if (cartId) {
|
|
1403
|
+
const cart = await omni.getCart(cartId);
|
|
1404
|
+
console.log(cart.items); // CartItem[]
|
|
1405
|
+
console.log(cart.itemCount); // Total items
|
|
1406
|
+
console.log(cart.subtotal); // Subtotal amount
|
|
1407
|
+
}
|
|
1408
|
+
```
|
|
1409
|
+
|
|
1410
|
+
#### Add to Cart
|
|
1411
|
+
|
|
1412
|
+
```typescript
|
|
1413
|
+
const cart = await omni.addToCart(cartId, {
|
|
1414
|
+
productId: 'product_id',
|
|
1415
|
+
variantId: 'variant_id', // Optional: for VARIABLE products
|
|
1416
|
+
quantity: 2,
|
|
1417
|
+
notes: 'Gift wrap please', // Optional
|
|
1418
|
+
});
|
|
1419
|
+
```
|
|
1420
|
+
|
|
1421
|
+
#### Update Cart Item
|
|
1422
|
+
|
|
1423
|
+
```typescript
|
|
1424
|
+
const cart = await omni.updateCartItem(cartId, itemId, {
|
|
1425
|
+
quantity: 3,
|
|
1426
|
+
});
|
|
1427
|
+
```
|
|
1428
|
+
|
|
1429
|
+
#### Remove Cart Item
|
|
1430
|
+
|
|
1431
|
+
```typescript
|
|
1432
|
+
const cart = await omni.removeCartItem(cartId, itemId);
|
|
1433
|
+
```
|
|
1434
|
+
|
|
1435
|
+
#### Apply Coupon
|
|
1436
|
+
|
|
1437
|
+
```typescript
|
|
1438
|
+
const cart = await omni.applyCoupon(cartId, 'SAVE20');
|
|
1439
|
+
console.log(cart.discountAmount); // Discount applied
|
|
1440
|
+
console.log(cart.couponCode); // 'SAVE20'
|
|
1441
|
+
```
|
|
1442
|
+
|
|
1443
|
+
#### Remove Coupon
|
|
1444
|
+
|
|
1445
|
+
```typescript
|
|
1446
|
+
const cart = await omni.removeCoupon(cartId);
|
|
1447
|
+
```
|
|
1448
|
+
|
|
1449
|
+
#### Cart Type Definition
|
|
1450
|
+
|
|
1451
|
+
```typescript
|
|
1452
|
+
interface Cart {
|
|
1453
|
+
id: string;
|
|
1454
|
+
sessionToken?: string | null;
|
|
1455
|
+
customerId?: string | null;
|
|
1456
|
+
status: 'ACTIVE' | 'MERGED' | 'CONVERTED' | 'ABANDONED';
|
|
1457
|
+
currency: string;
|
|
1458
|
+
subtotal: string;
|
|
1459
|
+
discountAmount: string;
|
|
1460
|
+
couponCode?: string | null;
|
|
1461
|
+
items: CartItem[];
|
|
1462
|
+
itemCount: number;
|
|
1463
|
+
createdAt: string;
|
|
1464
|
+
updatedAt: string;
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
interface CartItem {
|
|
1468
|
+
id: string;
|
|
1469
|
+
productId: string;
|
|
1470
|
+
variantId?: string | null;
|
|
1471
|
+
quantity: number;
|
|
1472
|
+
unitPrice: string;
|
|
1473
|
+
discountAmount: string;
|
|
1474
|
+
notes?: string | null;
|
|
1475
|
+
product: {
|
|
1476
|
+
id: string;
|
|
1477
|
+
name: string;
|
|
1478
|
+
sku: string;
|
|
1479
|
+
images?: unknown[];
|
|
1480
|
+
};
|
|
1481
|
+
variant?: {
|
|
1482
|
+
id: string;
|
|
1483
|
+
name?: string | null;
|
|
1484
|
+
sku?: string | null;
|
|
1485
|
+
} | null;
|
|
1486
|
+
}
|
|
1487
|
+
```
|
|
1488
|
+
|
|
1489
|
+
---
|
|
1490
|
+
|
|
1491
|
+
### Checkout
|
|
1492
|
+
|
|
1493
|
+
#### Create Checkout from Cart
|
|
1494
|
+
|
|
1495
|
+
```typescript
|
|
1496
|
+
const checkout = await omni.createCheckout({
|
|
1497
|
+
cartId: cartId,
|
|
1498
|
+
});
|
|
1499
|
+
```
|
|
1500
|
+
|
|
1501
|
+
#### Partial Checkout (AliExpress-style)
|
|
1502
|
+
|
|
1503
|
+
Allow customers to select which items to checkout from their cart. Only selected items are purchased - remaining items stay in the cart for later.
|
|
1504
|
+
|
|
1505
|
+
```typescript
|
|
1506
|
+
// 1. In your cart page, track selected items
|
|
1507
|
+
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
|
1508
|
+
|
|
1509
|
+
// 2. Create checkout with only selected items
|
|
1510
|
+
const checkout = await omni.createCheckout({
|
|
1511
|
+
cartId: cart.id,
|
|
1512
|
+
selectedItemIds: Array.from(selectedItems), // Only these items go to checkout
|
|
1513
|
+
});
|
|
1514
|
+
|
|
1515
|
+
// 3. Before checkout, check stock ONLY for selected items
|
|
1516
|
+
const stockCheck = await omni.checkCartStock(cart, Array.from(selectedItems));
|
|
1517
|
+
if (!stockCheck.allAvailable) {
|
|
1518
|
+
// Handle out-of-stock items
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
// After successful payment:
|
|
1522
|
+
// - Selected items are REMOVED from cart
|
|
1523
|
+
// - Unselected items REMAIN in cart (cart stays ACTIVE)
|
|
1524
|
+
// - Customer can continue shopping and checkout remaining items later
|
|
1525
|
+
```
|
|
1526
|
+
|
|
1527
|
+
#### Set Customer Information
|
|
1528
|
+
|
|
1529
|
+
```typescript
|
|
1530
|
+
const checkout = await omni.setCheckoutCustomer(checkoutId, {
|
|
1531
|
+
email: 'customer@example.com',
|
|
1532
|
+
firstName: 'John',
|
|
1533
|
+
lastName: 'Doe',
|
|
1534
|
+
phone: '+1234567890', // Optional
|
|
1535
|
+
});
|
|
1536
|
+
```
|
|
1537
|
+
|
|
1538
|
+
#### Set Shipping Address
|
|
1539
|
+
|
|
1540
|
+
```typescript
|
|
1541
|
+
const { checkout, rates } = await omni.setShippingAddress(checkoutId, {
|
|
1542
|
+
firstName: 'John',
|
|
1543
|
+
lastName: 'Doe',
|
|
1544
|
+
line1: '123 Main St',
|
|
1545
|
+
line2: 'Apt 4B', // Optional
|
|
1546
|
+
city: 'New York',
|
|
1547
|
+
region: 'NY', // State/Province
|
|
1548
|
+
postalCode: '10001',
|
|
1549
|
+
country: 'US',
|
|
1550
|
+
phone: '+1234567890', // Optional
|
|
1551
|
+
});
|
|
1552
|
+
|
|
1553
|
+
// rates contains available shipping options
|
|
1554
|
+
console.log(rates); // ShippingRate[]
|
|
1555
|
+
```
|
|
1556
|
+
|
|
1557
|
+
#### Select Shipping Method
|
|
1558
|
+
|
|
1559
|
+
```typescript
|
|
1560
|
+
const checkout = await omni.selectShippingMethod(checkoutId, rates[0].id);
|
|
1561
|
+
```
|
|
1562
|
+
|
|
1563
|
+
#### Set Billing Address
|
|
1564
|
+
|
|
1565
|
+
```typescript
|
|
1566
|
+
// Same as shipping
|
|
1567
|
+
const checkout = await omni.setBillingAddress(checkoutId, {
|
|
1568
|
+
...shippingAddress,
|
|
1569
|
+
sameAsShipping: true, // Optional shortcut
|
|
1570
|
+
});
|
|
1571
|
+
```
|
|
1572
|
+
|
|
1573
|
+
#### Complete Checkout
|
|
1574
|
+
|
|
1575
|
+
```typescript
|
|
1576
|
+
const { orderId } = await omni.completeCheckout(checkoutId);
|
|
1577
|
+
clearCartId(); // Clear cart from localStorage
|
|
1578
|
+
console.log('Order created:', orderId);
|
|
1579
|
+
```
|
|
1580
|
+
|
|
1581
|
+
#### Checkout Type Definition
|
|
1582
|
+
|
|
1583
|
+
```typescript
|
|
1584
|
+
interface Checkout {
|
|
1585
|
+
id: string;
|
|
1586
|
+
status: CheckoutStatus;
|
|
1587
|
+
email?: string | null;
|
|
1588
|
+
shippingAddress?: CheckoutAddress | null;
|
|
1589
|
+
billingAddress?: CheckoutAddress | null;
|
|
1590
|
+
shippingMethod?: ShippingRate | null;
|
|
1591
|
+
currency: string;
|
|
1592
|
+
subtotal: string;
|
|
1593
|
+
discountAmount: string;
|
|
1594
|
+
shippingAmount: string;
|
|
1595
|
+
taxAmount: string;
|
|
1596
|
+
total: string;
|
|
1597
|
+
couponCode?: string | null;
|
|
1598
|
+
items: CheckoutLineItem[];
|
|
1599
|
+
itemCount: number;
|
|
1600
|
+
availableShippingRates?: ShippingRate[];
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
type CheckoutStatus = 'PENDING' | 'SHIPPING_SET' | 'PAYMENT_PENDING' | 'COMPLETED' | 'FAILED';
|
|
1604
|
+
|
|
1605
|
+
interface ShippingRate {
|
|
1606
|
+
id: string;
|
|
1607
|
+
name: string;
|
|
1608
|
+
description?: string | null;
|
|
1609
|
+
price: string;
|
|
1610
|
+
currency: string;
|
|
1611
|
+
estimatedDays?: number | null;
|
|
1612
|
+
}
|
|
1613
|
+
```
|
|
1614
|
+
|
|
1615
|
+
#### Shipping Rates: Complete Flow
|
|
1616
|
+
|
|
1617
|
+
The shipping flow involves setting an address and then selecting from available rates:
|
|
1618
|
+
|
|
1619
|
+
```typescript
|
|
1620
|
+
// Step 1: Set shipping address - this returns available rates
|
|
1621
|
+
const { checkout, rates } = await omni.setShippingAddress(checkoutId, {
|
|
1622
|
+
firstName: 'John',
|
|
1623
|
+
lastName: 'Doe',
|
|
1624
|
+
line1: '123 Main St',
|
|
1625
|
+
city: 'New York',
|
|
1626
|
+
region: 'NY',
|
|
1627
|
+
postalCode: '10001',
|
|
1628
|
+
country: 'US',
|
|
1629
|
+
});
|
|
1630
|
+
|
|
1631
|
+
// Step 2: Handle empty rates (edge case)
|
|
1632
|
+
if (rates.length === 0) {
|
|
1633
|
+
// No shipping options available for this address
|
|
1634
|
+
// This can happen when:
|
|
1635
|
+
// - Store doesn't ship to this address/country
|
|
1636
|
+
// - All shipping methods have restrictions that exclude this address
|
|
1637
|
+
// - Shipping rates haven't been configured in the store
|
|
1638
|
+
|
|
1639
|
+
return (
|
|
1640
|
+
<div className="bg-yellow-50 p-4 rounded">
|
|
1641
|
+
<p className="font-medium">No shipping options available</p>
|
|
1642
|
+
<p className="text-sm text-gray-600">
|
|
1643
|
+
We currently cannot ship to this address. Please try a different address or contact us for
|
|
1644
|
+
assistance.
|
|
1645
|
+
</p>
|
|
1646
|
+
</div>
|
|
1647
|
+
);
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
// Step 3: Display available rates to customer
|
|
1651
|
+
<div className="space-y-2">
|
|
1652
|
+
<h3 className="font-medium">Select Shipping Method</h3>
|
|
1653
|
+
{rates.map((rate) => (
|
|
1654
|
+
<label key={rate.id} className="flex items-center gap-3 p-3 border rounded cursor-pointer">
|
|
1655
|
+
<input
|
|
1656
|
+
type="radio"
|
|
1657
|
+
name="shipping"
|
|
1658
|
+
value={rate.id}
|
|
1659
|
+
checked={selectedRateId === rate.id}
|
|
1660
|
+
onChange={() => setSelectedRateId(rate.id)}
|
|
1661
|
+
/>
|
|
1662
|
+
<div className="flex-1">
|
|
1663
|
+
<span className="font-medium">{rate.name}</span>
|
|
1664
|
+
{rate.description && <p className="text-sm text-gray-500">{rate.description}</p>}
|
|
1665
|
+
{rate.estimatedDays && (
|
|
1666
|
+
<p className="text-sm text-gray-500">Estimated delivery: {rate.estimatedDays} business days</p>
|
|
1667
|
+
)}
|
|
1668
|
+
</div>
|
|
1669
|
+
<span className="font-medium">${parseFloat(rate.price).toFixed(2)}</span>
|
|
1670
|
+
</label>
|
|
1671
|
+
))}
|
|
1672
|
+
</div>;
|
|
1673
|
+
|
|
1674
|
+
// Step 4: Select the shipping method
|
|
1675
|
+
await omni.selectShippingMethod(checkoutId, selectedRateId);
|
|
1676
|
+
```
|
|
1677
|
+
|
|
1678
|
+
**Handling Empty Shipping Rates:**
|
|
1679
|
+
|
|
1680
|
+
When no shipping rates are available, you have several options:
|
|
1681
|
+
|
|
1682
|
+
```typescript
|
|
1683
|
+
// Option 1: Show helpful message
|
|
1684
|
+
if (rates.length === 0) {
|
|
1685
|
+
return <NoShippingAvailable address={shippingAddress} />;
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
// Option 2: Allow customer to contact store
|
|
1689
|
+
if (rates.length === 0) {
|
|
1690
|
+
return (
|
|
1691
|
+
<div>
|
|
1692
|
+
<p>Shipping not available to your location.</p>
|
|
1693
|
+
<a href="/contact">Request a shipping quote</a>
|
|
1694
|
+
</div>
|
|
1695
|
+
);
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
// Option 3: Validate before proceeding
|
|
1699
|
+
function canProceedToPayment(checkout: Checkout, rates: ShippingRate[]): boolean {
|
|
1700
|
+
if (rates.length === 0) return false;
|
|
1701
|
+
if (!checkout.shippingRateId) return false;
|
|
1702
|
+
if (!checkout.email) return false;
|
|
1703
|
+
return true;
|
|
1704
|
+
}
|
|
1705
|
+
```
|
|
1706
|
+
|
|
1707
|
+
---
|
|
1708
|
+
|
|
1709
|
+
### Payment Integration (Vibe-Coded Sites)
|
|
1710
|
+
|
|
1711
|
+
For vibe-coded sites, the SDK provides payment integration with Stripe and PayPal. The store owner configures their payment provider(s) in the admin, and your site uses these methods to process payments.
|
|
1712
|
+
|
|
1713
|
+
#### ⚠️ Important: Getting a Valid Checkout ID
|
|
1714
|
+
|
|
1715
|
+
Before creating a payment intent, you need a checkout ID. How you get it depends on the customer type:
|
|
1716
|
+
|
|
1717
|
+
```typescript
|
|
1718
|
+
// For GUEST users (local cart in localStorage):
|
|
1719
|
+
const result = await omni.startGuestCheckout();
|
|
1720
|
+
const checkoutId = result.checkoutId;
|
|
1721
|
+
|
|
1722
|
+
// For LOGGED-IN users (server cart):
|
|
1723
|
+
const checkout = await omni.createCheckout({ cartId: serverCartId });
|
|
1724
|
+
const checkoutId = checkout.id;
|
|
1725
|
+
|
|
1726
|
+
// Then continue with shipping and payment...
|
|
1727
|
+
```
|
|
1728
|
+
|
|
1729
|
+
#### Get All Payment Providers (Recommended)
|
|
1730
|
+
|
|
1731
|
+
Use this method to get ALL enabled payment providers and build dynamic UI:
|
|
1732
|
+
|
|
1733
|
+
```typescript
|
|
1734
|
+
const { hasPayments, providers, defaultProvider } = await omni.getPaymentProviders();
|
|
1735
|
+
|
|
1736
|
+
// Returns:
|
|
1737
|
+
// {
|
|
1738
|
+
// hasPayments: true,
|
|
1739
|
+
// providers: [
|
|
1740
|
+
// {
|
|
1741
|
+
// id: 'provider_xxx',
|
|
1742
|
+
// provider: 'stripe',
|
|
1743
|
+
// name: 'Stripe',
|
|
1744
|
+
// publicKey: 'pk_live_xxx...',
|
|
1745
|
+
// supportedMethods: ['card', 'ideal'],
|
|
1746
|
+
// testMode: false,
|
|
1747
|
+
// isDefault: true
|
|
1748
|
+
// },
|
|
1749
|
+
// {
|
|
1750
|
+
// id: 'provider_yyy',
|
|
1751
|
+
// provider: 'paypal',
|
|
1752
|
+
// name: 'PayPal',
|
|
1753
|
+
// publicKey: 'client_id_xxx...',
|
|
1754
|
+
// supportedMethods: ['paypal'],
|
|
1755
|
+
// testMode: false,
|
|
1756
|
+
// isDefault: false
|
|
1757
|
+
// }
|
|
1758
|
+
// ],
|
|
1759
|
+
// defaultProvider: { ... } // The default provider (first one)
|
|
1760
|
+
// }
|
|
1761
|
+
|
|
1762
|
+
// Build dynamic UI based on available providers
|
|
1763
|
+
if (!hasPayments) {
|
|
1764
|
+
return <div>Payment not configured for this store</div>;
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
const stripeProvider = providers.find(p => p.provider === 'stripe');
|
|
1768
|
+
const paypalProvider = providers.find(p => p.provider === 'paypal');
|
|
1769
|
+
|
|
1770
|
+
// Show Stripe payment form if available
|
|
1771
|
+
if (stripeProvider) {
|
|
1772
|
+
const stripe = await loadStripe(stripeProvider.publicKey);
|
|
1773
|
+
// ... show Stripe Elements
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
// Show PayPal buttons if available
|
|
1777
|
+
if (paypalProvider) {
|
|
1778
|
+
// ... show PayPal buttons with paypalProvider.publicKey as client-id
|
|
1779
|
+
}
|
|
1780
|
+
```
|
|
1781
|
+
|
|
1782
|
+
#### Get Payment Configuration (Single Provider)
|
|
1783
|
+
|
|
1784
|
+
If you only need the default provider, use this simpler method:
|
|
1785
|
+
|
|
1786
|
+
```typescript
|
|
1787
|
+
const config = await omni.getPaymentConfig();
|
|
1788
|
+
|
|
1789
|
+
// Returns:
|
|
1790
|
+
// {
|
|
1791
|
+
// provider: 'stripe' | 'paypal',
|
|
1792
|
+
// publicKey: 'pk_live_xxx...', // Stripe publishable key or PayPal client ID
|
|
1793
|
+
// supportedMethods: ['card', 'ideal', 'bancontact'],
|
|
1794
|
+
// testMode: false
|
|
1795
|
+
// }
|
|
1796
|
+
```
|
|
1797
|
+
|
|
1798
|
+
#### Create Payment Intent
|
|
1799
|
+
|
|
1800
|
+
After the customer fills in shipping details, create a payment intent:
|
|
1801
|
+
|
|
1802
|
+
```typescript
|
|
1803
|
+
const intent = await omni.createPaymentIntent(checkout.id);
|
|
1804
|
+
|
|
1805
|
+
// Returns:
|
|
1806
|
+
// {
|
|
1807
|
+
// id: 'pi_xxx...',
|
|
1808
|
+
// clientSecret: 'pi_xxx_secret_xxx', // Used by Stripe.js/PayPal SDK
|
|
1809
|
+
// amount: 9999, // In cents
|
|
1810
|
+
// currency: 'USD',
|
|
1811
|
+
// status: 'requires_payment_method'
|
|
1812
|
+
// }
|
|
1813
|
+
```
|
|
1814
|
+
|
|
1815
|
+
#### Confirm Payment with Stripe.js
|
|
1816
|
+
|
|
1817
|
+
Use the client secret with Stripe.js to collect payment:
|
|
1818
|
+
|
|
1819
|
+
```typescript
|
|
1820
|
+
// Initialize Stripe.js with the public key from getPaymentConfig()
|
|
1821
|
+
const stripe = await loadStripe(config.publicKey);
|
|
1822
|
+
|
|
1823
|
+
// Create Elements and Payment Element
|
|
1824
|
+
const elements = stripe.elements({ clientSecret: intent.clientSecret });
|
|
1825
|
+
const paymentElement = elements.create('payment');
|
|
1826
|
+
paymentElement.mount('#payment-element');
|
|
1827
|
+
|
|
1828
|
+
// When customer submits payment
|
|
1829
|
+
const { error } = await stripe.confirmPayment({
|
|
1830
|
+
elements,
|
|
1831
|
+
confirmParams: {
|
|
1832
|
+
return_url: `${window.location.origin}/checkout/success?checkout_id=${checkout.id}`,
|
|
1833
|
+
},
|
|
1834
|
+
});
|
|
1835
|
+
|
|
1836
|
+
if (error) {
|
|
1837
|
+
console.error('Payment failed:', error.message);
|
|
1838
|
+
}
|
|
1839
|
+
```
|
|
1840
|
+
|
|
1841
|
+
#### After Payment: Success Page Pattern (Recommended)
|
|
1842
|
+
|
|
1843
|
+
**Important:** Orders are created asynchronously via webhook after Stripe confirms payment.
|
|
1844
|
+
This typically takes 1-5 seconds, but can vary. Follow these best practices:
|
|
1845
|
+
|
|
1846
|
+
**Option 1: Optimistic Success Page (Recommended - Used by Amazon, Shopify, AliExpress)**
|
|
1847
|
+
|
|
1848
|
+
Show success immediately without waiting for orderId. This is the industry standard:
|
|
1849
|
+
|
|
1850
|
+
```typescript
|
|
1851
|
+
// In your payment form, after stripe.confirmPayment() succeeds:
|
|
1852
|
+
const { error } = await stripe.confirmPayment({
|
|
1853
|
+
elements,
|
|
1854
|
+
confirmParams: {
|
|
1855
|
+
return_url: `${window.location.origin}/checkout/success?checkout_id=${checkout.id}`,
|
|
1856
|
+
},
|
|
1857
|
+
});
|
|
1858
|
+
|
|
1859
|
+
// On /checkout/success page - show confirmation IMMEDIATELY:
|
|
1860
|
+
export default function CheckoutSuccessPage() {
|
|
1861
|
+
const checkoutId = new URLSearchParams(window.location.search).get('checkout_id');
|
|
1862
|
+
|
|
1863
|
+
return (
|
|
1864
|
+
<div className="text-center py-12">
|
|
1865
|
+
<h1 className="text-2xl font-bold text-green-600">Payment Received!</h1>
|
|
1866
|
+
<p className="mt-4">Your order is being processed.</p>
|
|
1867
|
+
<p className="mt-2 text-gray-600">
|
|
1868
|
+
Confirmation #{checkoutId?.slice(-8).toUpperCase()}
|
|
1869
|
+
</p>
|
|
1870
|
+
<p className="mt-4">A confirmation email will be sent shortly.</p>
|
|
1871
|
+
<a href="/orders" className="mt-6 inline-block text-blue-600">
|
|
1872
|
+
View Your Orders →
|
|
1873
|
+
</a>
|
|
1874
|
+
</div>
|
|
1875
|
+
);
|
|
1876
|
+
}
|
|
1877
|
+
```
|
|
1878
|
+
|
|
1879
|
+
**Option 2: Wait for Order (For SPAs that need orderId)**
|
|
1880
|
+
|
|
1881
|
+
Use `waitForOrder()` to poll in the background with exponential backoff:
|
|
1882
|
+
|
|
1883
|
+
```typescript
|
|
1884
|
+
// After payment succeeds, wait for order creation (max 30 seconds)
|
|
1885
|
+
const result = await omni.waitForOrder(checkout.id, {
|
|
1886
|
+
maxWaitMs: 30000, // 30 seconds max
|
|
1887
|
+
onPollAttempt: (attempt, status) => {
|
|
1888
|
+
console.log(`Checking order status... (attempt ${attempt})`);
|
|
1889
|
+
},
|
|
1890
|
+
onOrderReady: (status) => {
|
|
1891
|
+
// Called immediately when order is created
|
|
1892
|
+
console.log('Order ready:', status.orderNumber);
|
|
1893
|
+
},
|
|
1894
|
+
});
|
|
1895
|
+
|
|
1896
|
+
if (result.success) {
|
|
1897
|
+
// Order was created within timeout
|
|
1898
|
+
window.location.href = `/orders/${result.status.orderId}`;
|
|
1899
|
+
} else {
|
|
1900
|
+
// Order not created yet - show optimistic success anyway
|
|
1901
|
+
// The email will be sent when order is ready
|
|
1902
|
+
showSuccessMessage('Payment received! Order confirmation coming soon.');
|
|
1903
|
+
}
|
|
1904
|
+
```
|
|
1905
|
+
|
|
1906
|
+
**Option 3: Simple Status Check (Single poll)**
|
|
1907
|
+
|
|
1908
|
+
For simple use cases where you just want to check once:
|
|
1909
|
+
|
|
1910
|
+
```typescript
|
|
1911
|
+
const status = await omni.getPaymentStatus(checkout.id);
|
|
1912
|
+
|
|
1913
|
+
// Returns:
|
|
1914
|
+
// {
|
|
1915
|
+
// checkoutId: 'checkout_xxx',
|
|
1916
|
+
// status: 'succeeded' | 'pending' | 'failed' | 'canceled',
|
|
1917
|
+
// orderId: 'order_xxx', // Only if order was created
|
|
1918
|
+
// orderNumber: 'ORD-123', // Only if order was created
|
|
1919
|
+
// error: 'Payment declined' // Only if payment failed
|
|
1920
|
+
// }
|
|
1921
|
+
|
|
1922
|
+
if (status.status === 'succeeded' && status.orderId) {
|
|
1923
|
+
window.location.href = `/order-confirmation/${status.orderId}`;
|
|
1924
|
+
} else if (status.status === 'succeeded') {
|
|
1925
|
+
// Payment succeeded but order not created yet
|
|
1926
|
+
showMessage('Payment received, processing your order...');
|
|
1927
|
+
} else if (status.status === 'failed') {
|
|
1928
|
+
showError(status.error || 'Payment failed');
|
|
1929
|
+
}
|
|
1930
|
+
```
|
|
1931
|
+
|
|
1932
|
+
> **Why Optimistic Success?** Stripe webhooks typically arrive within 1-5 seconds,
|
|
1933
|
+
> but network issues can cause delays. Major e-commerce platforms (Amazon, Shopify)
|
|
1934
|
+
> show success immediately and send order details via email. This provides better UX
|
|
1935
|
+
> than making customers wait on a loading screen.
|
|
1936
|
+
|
|
1937
|
+
#### Complete Checkout with Payment Example
|
|
1938
|
+
|
|
1939
|
+
> **Note:** This example assumes you already have a `checkout_id`. See below for how to create one.
|
|
1940
|
+
|
|
1941
|
+
**How to get a checkout_id:**
|
|
1942
|
+
|
|
1943
|
+
```typescript
|
|
1944
|
+
// For GUEST users (local cart):
|
|
1945
|
+
const result = await omni.startGuestCheckout();
|
|
1946
|
+
const checkoutId = result.checkoutId; // Use this!
|
|
1947
|
+
|
|
1948
|
+
// For LOGGED-IN users (server cart):
|
|
1949
|
+
const checkout = await omni.createCheckout({ cartId: cart.id });
|
|
1950
|
+
const checkoutId = checkout.id; // Use this!
|
|
1951
|
+
```
|
|
1952
|
+
|
|
1953
|
+
```typescript
|
|
1954
|
+
'use client';
|
|
1955
|
+
import { useState, useEffect } from 'react';
|
|
1956
|
+
import { loadStripe, Stripe, StripeElements } from '@stripe/stripe-js';
|
|
1957
|
+
import { Elements, PaymentElement, useStripe, useElements } from '@stripe/react-stripe-js';
|
|
1958
|
+
import { omni } from '@/lib/brainerce';
|
|
1959
|
+
import type { Checkout, PaymentConfig, PaymentIntent } from 'brainerce';
|
|
1960
|
+
|
|
1961
|
+
export default function CheckoutPaymentPage() {
|
|
1962
|
+
const [checkout, setCheckout] = useState<Checkout | null>(null);
|
|
1963
|
+
const [paymentConfig, setPaymentConfig] = useState<PaymentConfig | null>(null);
|
|
1964
|
+
const [paymentIntent, setPaymentIntent] = useState<PaymentIntent | null>(null);
|
|
1965
|
+
const [stripePromise, setStripePromise] = useState<Promise<Stripe | null> | null>(null);
|
|
1966
|
+
const [loading, setLoading] = useState(true);
|
|
1967
|
+
const [error, setError] = useState('');
|
|
1968
|
+
|
|
1969
|
+
useEffect(() => {
|
|
1970
|
+
async function initPayment() {
|
|
1971
|
+
try {
|
|
1972
|
+
// Get checkout_id from URL (set by previous step)
|
|
1973
|
+
const checkoutId = new URLSearchParams(window.location.search).get('checkout_id');
|
|
1974
|
+
if (!checkoutId) throw new Error('No checkout ID');
|
|
1975
|
+
|
|
1976
|
+
// Get payment configuration
|
|
1977
|
+
const config = await omni.getPaymentConfig();
|
|
1978
|
+
setPaymentConfig(config);
|
|
1979
|
+
|
|
1980
|
+
// Initialize Stripe
|
|
1981
|
+
if (config.provider === 'stripe') {
|
|
1982
|
+
setStripePromise(loadStripe(config.publicKey));
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
// Create payment intent
|
|
1986
|
+
const intent = await omni.createPaymentIntent(checkoutId);
|
|
1987
|
+
setPaymentIntent(intent);
|
|
1988
|
+
} catch (err) {
|
|
1989
|
+
setError(err instanceof Error ? err.message : 'Failed to initialize payment');
|
|
1990
|
+
} finally {
|
|
1991
|
+
setLoading(false);
|
|
1992
|
+
}
|
|
1993
|
+
}
|
|
1994
|
+
initPayment();
|
|
1995
|
+
}, []);
|
|
1996
|
+
|
|
1997
|
+
if (loading) return <div>Loading payment...</div>;
|
|
1998
|
+
if (error) return <div className="text-red-600">{error}</div>;
|
|
1999
|
+
if (!paymentConfig || !paymentIntent || !stripePromise) return null;
|
|
2000
|
+
|
|
2001
|
+
return (
|
|
2002
|
+
<div className="max-w-lg mx-auto p-6">
|
|
2003
|
+
<h1 className="text-2xl font-bold mb-6">Payment</h1>
|
|
2004
|
+
|
|
2005
|
+
<Elements stripe={stripePromise} options={{ clientSecret: paymentIntent.clientSecret }}>
|
|
2006
|
+
<PaymentForm checkoutId={paymentIntent.id} />
|
|
2007
|
+
</Elements>
|
|
2008
|
+
</div>
|
|
2009
|
+
);
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
function PaymentForm({ checkoutId }: { checkoutId: string }) {
|
|
2013
|
+
const stripe = useStripe();
|
|
2014
|
+
const elements = useElements();
|
|
2015
|
+
const [processing, setProcessing] = useState(false);
|
|
2016
|
+
const [error, setError] = useState('');
|
|
2017
|
+
|
|
2018
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
2019
|
+
e.preventDefault();
|
|
2020
|
+
if (!stripe || !elements) return;
|
|
2021
|
+
|
|
2022
|
+
setProcessing(true);
|
|
2023
|
+
setError('');
|
|
2024
|
+
|
|
2025
|
+
const { error } = await stripe.confirmPayment({
|
|
2026
|
+
elements,
|
|
2027
|
+
confirmParams: {
|
|
2028
|
+
return_url: `${window.location.origin}/checkout/success?checkout_id=${checkoutId}`,
|
|
2029
|
+
},
|
|
2030
|
+
});
|
|
2031
|
+
|
|
2032
|
+
if (error) {
|
|
2033
|
+
setError(error.message || 'Payment failed');
|
|
2034
|
+
setProcessing(false);
|
|
2035
|
+
}
|
|
2036
|
+
// If successful, Stripe redirects to return_url
|
|
2037
|
+
};
|
|
2038
|
+
|
|
2039
|
+
return (
|
|
2040
|
+
<form onSubmit={handleSubmit}>
|
|
2041
|
+
<PaymentElement />
|
|
2042
|
+
|
|
2043
|
+
{error && <div className="text-red-600 mt-4">{error}</div>}
|
|
2044
|
+
|
|
2045
|
+
<button
|
|
2046
|
+
type="submit"
|
|
2047
|
+
disabled={!stripe || processing}
|
|
2048
|
+
className="w-full mt-6 bg-green-600 text-white py-3 rounded disabled:opacity-50"
|
|
2049
|
+
>
|
|
2050
|
+
{processing ? 'Processing...' : 'Pay Now'}
|
|
2051
|
+
</button>
|
|
2052
|
+
</form>
|
|
2053
|
+
);
|
|
2054
|
+
}
|
|
2055
|
+
```
|
|
2056
|
+
|
|
2057
|
+
#### Complete Order After Payment: `completeGuestCheckout()`
|
|
2058
|
+
|
|
2059
|
+
**CRITICAL:** After payment succeeds, you MUST call `completeGuestCheckout()` to create the order on the server and clear the cart.
|
|
2060
|
+
|
|
2061
|
+
> **WARNING:** Do NOT use `handlePaymentSuccess()` - it only clears the local cart (localStorage)
|
|
2062
|
+
> and does NOT send the order to the server. Your customer will pay but no order will be created!
|
|
2063
|
+
|
|
2064
|
+
```typescript
|
|
2065
|
+
// On your /checkout/success page:
|
|
2066
|
+
export default function CheckoutSuccessPage() {
|
|
2067
|
+
const checkoutId = new URLSearchParams(window.location.search).get('checkout_id');
|
|
2068
|
+
|
|
2069
|
+
useEffect(() => {
|
|
2070
|
+
if (checkoutId) {
|
|
2071
|
+
// IMPORTANT: This sends the order to the server AND clears the cart
|
|
2072
|
+
// completeGuestCheckout() returns GuestOrderResponse (has .orderNumber directly)
|
|
2073
|
+
// This is different from waitForOrder() which returns WaitForOrderResult
|
|
2074
|
+
// (access orderNumber via .status.orderNumber instead)
|
|
2075
|
+
omni.completeGuestCheckout(checkoutId).then(result => {
|
|
2076
|
+
console.log('Order created:', result.orderNumber);
|
|
2077
|
+
}).catch(() => {
|
|
2078
|
+
// Order may already exist (e.g., page refresh) - safe to ignore
|
|
2079
|
+
});
|
|
2080
|
+
}
|
|
2081
|
+
}, []);
|
|
2082
|
+
|
|
2083
|
+
return (
|
|
2084
|
+
<div className="text-center py-12">
|
|
2085
|
+
<h1 className="text-2xl font-bold text-green-600">Payment Received!</h1>
|
|
2086
|
+
{/* ... rest of success page */}
|
|
2087
|
+
</div>
|
|
2088
|
+
);
|
|
2089
|
+
}
|
|
2090
|
+
```
|
|
2091
|
+
|
|
2092
|
+
**How it works:**
|
|
2093
|
+
| User Type | Cart Type | Behavior |
|
|
2094
|
+
|-----------|-----------|----------|
|
|
2095
|
+
| Guest (partial checkout) | Local cart | Creates order + removes only purchased items |
|
|
2096
|
+
| Guest (full checkout) | Local cart | Creates order + clears entire cart |
|
|
2097
|
+
| Logged-in | Server cart | Creates order + clears cart via SDK state |
|
|
2098
|
+
|
|
2099
|
+
**Why is this needed?**
|
|
2100
|
+
|
|
2101
|
+
- `completeGuestCheckout()` sends `POST /checkout/:id/complete` which creates the order on the server
|
|
2102
|
+
- Without it, payment goes through Stripe but no order is created in the system
|
|
2103
|
+
- The server also links the order to the customer (by email) so it appears in their order history
|
|
2104
|
+
- For partial checkout (AliExpress-style), only the purchased items are removed
|
|
2105
|
+
|
|
2106
|
+
---
|
|
2107
|
+
|
|
2108
|
+
### Customer Authentication
|
|
2109
|
+
|
|
2110
|
+
#### Register Customer
|
|
2111
|
+
|
|
2112
|
+
```typescript
|
|
2113
|
+
const auth = await omni.registerCustomer({
|
|
2114
|
+
email: 'customer@example.com',
|
|
2115
|
+
password: 'securepassword123',
|
|
2116
|
+
firstName: 'John',
|
|
2117
|
+
lastName: 'Doe',
|
|
2118
|
+
});
|
|
2119
|
+
|
|
2120
|
+
// Check if email verification is required
|
|
2121
|
+
if (auth.requiresVerification) {
|
|
2122
|
+
localStorage.setItem('verificationToken', auth.token);
|
|
2123
|
+
window.location.href = '/verify-email';
|
|
2124
|
+
} else {
|
|
2125
|
+
setCustomerToken(auth.token);
|
|
2126
|
+
// Redirect back to store, not /account
|
|
2127
|
+
window.location.href = '/';
|
|
2128
|
+
}
|
|
2129
|
+
```
|
|
2130
|
+
|
|
2131
|
+
#### Login Customer
|
|
2132
|
+
|
|
2133
|
+
```typescript
|
|
2134
|
+
const auth = await omni.loginCustomer('customer@example.com', 'password123');
|
|
2135
|
+
setCustomerToken(auth.token);
|
|
2136
|
+
|
|
2137
|
+
// Best practice: redirect back to previous page or home
|
|
2138
|
+
const returnUrl = localStorage.getItem('returnUrl') || '/';
|
|
2139
|
+
localStorage.removeItem('returnUrl');
|
|
2140
|
+
window.location.href = returnUrl;
|
|
2141
|
+
```
|
|
2142
|
+
|
|
2143
|
+
> **Best Practice:** Before showing login page, save the current URL with `localStorage.setItem('returnUrl', window.location.pathname)`. After login, redirect back to that URL. This is how Amazon, Shopify, and most e-commerce sites work.
|
|
2144
|
+
|
|
2145
|
+
#### Logout Customer
|
|
2146
|
+
|
|
2147
|
+
```typescript
|
|
2148
|
+
setCustomerToken(null);
|
|
2149
|
+
window.location.href = '/'; // Return to store home
|
|
2150
|
+
```
|
|
2151
|
+
|
|
2152
|
+
#### Get Customer Profile
|
|
2153
|
+
|
|
2154
|
+
```typescript
|
|
2155
|
+
restoreCustomerToken(); // Restore from localStorage
|
|
2156
|
+
const profile = await omni.getMyProfile();
|
|
2157
|
+
|
|
2158
|
+
console.log(profile.firstName);
|
|
2159
|
+
console.log(profile.email);
|
|
2160
|
+
console.log(profile.addresses);
|
|
2161
|
+
```
|
|
2162
|
+
|
|
2163
|
+
#### Get Customer Orders
|
|
2164
|
+
|
|
2165
|
+
```typescript
|
|
2166
|
+
const { data: orders, meta } = await omni.getMyOrders({
|
|
2167
|
+
page: 1,
|
|
2168
|
+
limit: 10,
|
|
2169
|
+
});
|
|
2170
|
+
```
|
|
2171
|
+
|
|
2172
|
+
#### Auth Response Type
|
|
2173
|
+
|
|
2174
|
+
```typescript
|
|
2175
|
+
interface CustomerAuthResponse {
|
|
2176
|
+
customer: {
|
|
2177
|
+
id: string;
|
|
2178
|
+
email: string;
|
|
2179
|
+
firstName?: string;
|
|
2180
|
+
lastName?: string;
|
|
2181
|
+
emailVerified: boolean;
|
|
2182
|
+
};
|
|
2183
|
+
token: string;
|
|
2184
|
+
expiresAt: string;
|
|
2185
|
+
requiresVerification?: boolean; // true if email verification is required
|
|
2186
|
+
}
|
|
2187
|
+
```
|
|
2188
|
+
|
|
2189
|
+
---
|
|
2190
|
+
|
|
2191
|
+
### Email Verification
|
|
2192
|
+
|
|
2193
|
+
If the store has **email verification enabled**, customers must verify their email after registration before they can fully use their account.
|
|
2194
|
+
|
|
2195
|
+
#### Registration with Email Verification
|
|
2196
|
+
|
|
2197
|
+
When `requiresVerification` is true in the registration response, the customer needs to verify their email:
|
|
2198
|
+
|
|
2199
|
+
```typescript
|
|
2200
|
+
const auth = await omni.registerCustomer({
|
|
2201
|
+
email: 'customer@example.com',
|
|
2202
|
+
password: 'securepassword123',
|
|
2203
|
+
firstName: 'John',
|
|
2204
|
+
});
|
|
2205
|
+
|
|
2206
|
+
if (auth.requiresVerification) {
|
|
2207
|
+
// Save token for verification step
|
|
2208
|
+
localStorage.setItem('verificationToken', auth.token);
|
|
2209
|
+
// Redirect to verification page
|
|
2210
|
+
window.location.href = '/verify-email';
|
|
2211
|
+
} else {
|
|
2212
|
+
// No verification needed - redirect back to store
|
|
2213
|
+
setCustomerToken(auth.token);
|
|
2214
|
+
window.location.href = '/';
|
|
2215
|
+
}
|
|
2216
|
+
```
|
|
2217
|
+
|
|
2218
|
+
#### Verify Email with Code
|
|
2219
|
+
|
|
2220
|
+
After the customer receives the 6-digit code via email:
|
|
2221
|
+
|
|
2222
|
+
```typescript
|
|
2223
|
+
// Get the token saved from registration
|
|
2224
|
+
const token = localStorage.getItem('verificationToken');
|
|
2225
|
+
|
|
2226
|
+
// Verify email - pass the token directly (no need to call setCustomerToken first!)
|
|
2227
|
+
const result = await omni.verifyEmail(code, token);
|
|
2228
|
+
|
|
2229
|
+
if (result.verified) {
|
|
2230
|
+
// Email verified! Now set the token for normal use
|
|
2231
|
+
setCustomerToken(token);
|
|
2232
|
+
localStorage.removeItem('verificationToken');
|
|
2233
|
+
// Redirect back to store (or returnUrl if saved)
|
|
2234
|
+
const returnUrl = localStorage.getItem('returnUrl') || '/';
|
|
2235
|
+
localStorage.removeItem('returnUrl');
|
|
2236
|
+
window.location.href = returnUrl;
|
|
2237
|
+
}
|
|
2238
|
+
```
|
|
2239
|
+
|
|
2240
|
+
#### Resend Verification Email
|
|
2241
|
+
|
|
2242
|
+
If the customer didn't receive the email or the code expired:
|
|
2243
|
+
|
|
2244
|
+
```typescript
|
|
2245
|
+
const token = localStorage.getItem('verificationToken');
|
|
2246
|
+
await omni.resendVerificationEmail(token);
|
|
2247
|
+
// Show success message - new code sent
|
|
2248
|
+
```
|
|
2249
|
+
|
|
2250
|
+
> **Note:** Resend is rate-limited to 3 requests per hour.
|
|
2251
|
+
|
|
2252
|
+
#### Complete Email Verification Page Example
|
|
2253
|
+
|
|
2254
|
+
```typescript
|
|
2255
|
+
'use client';
|
|
2256
|
+
import { useState } from 'react';
|
|
2257
|
+
import { omni, setCustomerToken } from '@/lib/brainerce';
|
|
2258
|
+
import { toast } from 'sonner';
|
|
2259
|
+
|
|
2260
|
+
export default function VerifyEmailPage() {
|
|
2261
|
+
const [code, setCode] = useState('');
|
|
2262
|
+
const [loading, setLoading] = useState(false);
|
|
2263
|
+
|
|
2264
|
+
const handleVerify = async (e: React.FormEvent) => {
|
|
2265
|
+
e.preventDefault();
|
|
2266
|
+
setLoading(true);
|
|
2267
|
+
|
|
2268
|
+
try {
|
|
2269
|
+
const token = localStorage.getItem('verificationToken');
|
|
2270
|
+
if (!token) {
|
|
2271
|
+
toast.error('No verification token found. Please register again.');
|
|
2272
|
+
return;
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
const result = await omni.verifyEmail(code, token);
|
|
2276
|
+
|
|
2277
|
+
if (result.verified) {
|
|
2278
|
+
toast.success('Email verified!');
|
|
2279
|
+
setCustomerToken(token);
|
|
2280
|
+
localStorage.removeItem('verificationToken');
|
|
2281
|
+
// Redirect back to store
|
|
2282
|
+
const returnUrl = localStorage.getItem('returnUrl') || '/';
|
|
2283
|
+
localStorage.removeItem('returnUrl');
|
|
2284
|
+
window.location.href = returnUrl;
|
|
2285
|
+
}
|
|
2286
|
+
} catch (error) {
|
|
2287
|
+
toast.error(error instanceof Error ? error.message : 'Verification failed');
|
|
2288
|
+
} finally {
|
|
2289
|
+
setLoading(false);
|
|
2290
|
+
}
|
|
2291
|
+
};
|
|
2292
|
+
|
|
2293
|
+
const handleResend = async () => {
|
|
2294
|
+
try {
|
|
2295
|
+
const token = localStorage.getItem('verificationToken');
|
|
2296
|
+
if (!token) {
|
|
2297
|
+
toast.error('No verification token found');
|
|
2298
|
+
return;
|
|
2299
|
+
}
|
|
2300
|
+
await omni.resendVerificationEmail(token);
|
|
2301
|
+
toast.success('Verification code sent!');
|
|
2302
|
+
} catch (error) {
|
|
2303
|
+
toast.error(error instanceof Error ? error.message : 'Failed to resend');
|
|
2304
|
+
}
|
|
2305
|
+
};
|
|
2306
|
+
|
|
2307
|
+
return (
|
|
2308
|
+
<div className="max-w-md mx-auto mt-12">
|
|
2309
|
+
<h1 className="text-2xl font-bold mb-4">Verify Your Email</h1>
|
|
2310
|
+
<p className="text-gray-600 mb-6">
|
|
2311
|
+
We sent a 6-digit code to your email. Enter it below to verify your account.
|
|
2312
|
+
</p>
|
|
2313
|
+
|
|
2314
|
+
<form onSubmit={handleVerify} className="space-y-4">
|
|
2315
|
+
<input
|
|
2316
|
+
type="text"
|
|
2317
|
+
placeholder="Enter 6-digit code"
|
|
2318
|
+
value={code}
|
|
2319
|
+
onChange={(e) => setCode(e.target.value)}
|
|
2320
|
+
maxLength={6}
|
|
2321
|
+
className="w-full border p-3 rounded text-center text-2xl tracking-widest"
|
|
2322
|
+
required
|
|
2323
|
+
/>
|
|
2324
|
+
<button
|
|
2325
|
+
type="submit"
|
|
2326
|
+
disabled={loading || code.length !== 6}
|
|
2327
|
+
className="w-full bg-black text-white py-3 rounded disabled:opacity-50"
|
|
2328
|
+
>
|
|
2329
|
+
{loading ? 'Verifying...' : 'Verify Email'}
|
|
2330
|
+
</button>
|
|
2331
|
+
</form>
|
|
2332
|
+
|
|
2333
|
+
<button onClick={handleResend} className="mt-4 text-blue-600 text-sm">
|
|
2334
|
+
Didn't receive the code? Resend
|
|
2335
|
+
</button>
|
|
2336
|
+
</div>
|
|
2337
|
+
);
|
|
2338
|
+
}
|
|
2339
|
+
```
|
|
2340
|
+
|
|
2341
|
+
---
|
|
2342
|
+
|
|
2343
|
+
### Social Login (OAuth)
|
|
2344
|
+
|
|
2345
|
+
Allow customers to sign in with Google, Facebook, or GitHub. The store owner configures which providers are available in their Brainerce admin panel.
|
|
2346
|
+
|
|
2347
|
+
#### Check Available Providers
|
|
2348
|
+
|
|
2349
|
+
```typescript
|
|
2350
|
+
// Returns only the providers the store owner has enabled
|
|
2351
|
+
const { providers } = await omni.getAvailableOAuthProviders();
|
|
2352
|
+
// providers = ['GOOGLE', 'FACEBOOK'] - varies by store configuration
|
|
2353
|
+
```
|
|
2354
|
+
|
|
2355
|
+
#### OAuth Login Flow
|
|
2356
|
+
|
|
2357
|
+
**Step 1: User clicks "Sign in with Google"**
|
|
2358
|
+
|
|
2359
|
+
```typescript
|
|
2360
|
+
// Save cart ID before redirect (user will leave your site!)
|
|
2361
|
+
const cartId = localStorage.getItem('cartId');
|
|
2362
|
+
if (cartId) sessionStorage.setItem('pendingCartId', cartId);
|
|
2363
|
+
|
|
2364
|
+
// Get authorization URL
|
|
2365
|
+
const { authorizationUrl, state } = await omni.getOAuthAuthorizeUrl('GOOGLE', {
|
|
2366
|
+
redirectUrl: window.location.origin + '/auth/callback', // Where Google sends them back
|
|
2367
|
+
});
|
|
2368
|
+
|
|
2369
|
+
// Save state for verification (CSRF protection)
|
|
2370
|
+
sessionStorage.setItem('oauthState', state);
|
|
2371
|
+
|
|
2372
|
+
// Redirect to Google
|
|
2373
|
+
window.location.href = authorizationUrl;
|
|
2374
|
+
```
|
|
2375
|
+
|
|
2376
|
+
**Step 2: Create callback page (`/auth/callback`)**
|
|
2377
|
+
|
|
2378
|
+
```typescript
|
|
2379
|
+
// pages/auth/callback.tsx or app/auth/callback/page.tsx
|
|
2380
|
+
'use client';
|
|
2381
|
+
import { useEffect, useState } from 'react';
|
|
2382
|
+
import { useSearchParams } from 'next/navigation';
|
|
2383
|
+
import { omni, setCustomerToken } from '@/lib/brainerce';
|
|
2384
|
+
|
|
2385
|
+
export default function AuthCallback() {
|
|
2386
|
+
const searchParams = useSearchParams();
|
|
2387
|
+
const [error, setError] = useState<string | null>(null);
|
|
2388
|
+
|
|
2389
|
+
useEffect(() => {
|
|
2390
|
+
async function handleCallback() {
|
|
2391
|
+
const code = searchParams.get('code');
|
|
2392
|
+
const state = searchParams.get('state');
|
|
2393
|
+
const errorParam = searchParams.get('error');
|
|
2394
|
+
|
|
2395
|
+
// Check for OAuth errors (user cancelled, etc.)
|
|
2396
|
+
if (errorParam) {
|
|
2397
|
+
window.location.href = '/login?error=cancelled';
|
|
2398
|
+
return;
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
if (!code || !state) {
|
|
2402
|
+
setError('Missing OAuth parameters');
|
|
2403
|
+
return;
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2406
|
+
// Verify state matches (CSRF protection)
|
|
2407
|
+
const savedState = sessionStorage.getItem('oauthState');
|
|
2408
|
+
if (state !== savedState) {
|
|
2409
|
+
setError('Invalid state - please try again');
|
|
2410
|
+
return;
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
try {
|
|
2414
|
+
// Exchange code for customer token
|
|
2415
|
+
const { customer, token, isNewCustomer } = await omni.handleOAuthCallback(
|
|
2416
|
+
'GOOGLE',
|
|
2417
|
+
code,
|
|
2418
|
+
state
|
|
2419
|
+
);
|
|
2420
|
+
|
|
2421
|
+
// Save the customer token
|
|
2422
|
+
setCustomerToken(token);
|
|
2423
|
+
sessionStorage.removeItem('oauthState');
|
|
2424
|
+
|
|
2425
|
+
// Link any pending cart to the now-logged-in customer
|
|
2426
|
+
const pendingCartId = sessionStorage.getItem('pendingCartId');
|
|
2427
|
+
if (pendingCartId) {
|
|
2428
|
+
try {
|
|
2429
|
+
await omni.linkCart(pendingCartId);
|
|
2430
|
+
} catch {
|
|
2431
|
+
// Cart may have expired - that's ok
|
|
2432
|
+
}
|
|
2433
|
+
sessionStorage.removeItem('pendingCartId');
|
|
2434
|
+
}
|
|
2435
|
+
|
|
2436
|
+
// Redirect to return URL or home
|
|
2437
|
+
const returnUrl = localStorage.getItem('returnUrl') || '/';
|
|
2438
|
+
localStorage.removeItem('returnUrl');
|
|
2439
|
+
window.location.href = returnUrl;
|
|
2440
|
+
} catch (err) {
|
|
2441
|
+
setError(err instanceof Error ? err.message : 'Login failed');
|
|
2442
|
+
}
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
handleCallback();
|
|
2446
|
+
}, [searchParams]);
|
|
2447
|
+
|
|
2448
|
+
if (error) {
|
|
2449
|
+
return (
|
|
2450
|
+
<div className="max-w-md mx-auto mt-12 text-center">
|
|
2451
|
+
<h1 className="text-xl font-bold text-red-600">Login Failed</h1>
|
|
2452
|
+
<p className="mt-2 text-gray-600">{error}</p>
|
|
2453
|
+
<a href="/login" className="mt-4 inline-block text-blue-600">
|
|
2454
|
+
Try again
|
|
2455
|
+
</a>
|
|
2456
|
+
</div>
|
|
2457
|
+
);
|
|
2458
|
+
}
|
|
2459
|
+
|
|
2460
|
+
return (
|
|
2461
|
+
<div className="max-w-md mx-auto mt-12 text-center">
|
|
2462
|
+
<div className="animate-spin h-8 w-8 border-4 border-blue-600 border-t-transparent rounded-full mx-auto"></div>
|
|
2463
|
+
<p className="mt-4 text-gray-600">Completing login...</p>
|
|
2464
|
+
</div>
|
|
2465
|
+
);
|
|
2466
|
+
}
|
|
2467
|
+
```
|
|
2468
|
+
|
|
2469
|
+
#### Login Page with Social Buttons
|
|
2470
|
+
|
|
2471
|
+
```typescript
|
|
2472
|
+
'use client';
|
|
2473
|
+
import { useState, useEffect } from 'react';
|
|
2474
|
+
import { omni, setCustomerToken } from '@/lib/brainerce';
|
|
2475
|
+
|
|
2476
|
+
export default function LoginPage() {
|
|
2477
|
+
const [providers, setProviders] = useState<string[]>([]);
|
|
2478
|
+
const [email, setEmail] = useState('');
|
|
2479
|
+
const [password, setPassword] = useState('');
|
|
2480
|
+
const [loading, setLoading] = useState(false);
|
|
2481
|
+
const [error, setError] = useState('');
|
|
2482
|
+
|
|
2483
|
+
// Load available OAuth providers
|
|
2484
|
+
useEffect(() => {
|
|
2485
|
+
omni.getAvailableOAuthProviders()
|
|
2486
|
+
.then(({ providers }) => setProviders(providers))
|
|
2487
|
+
.catch(() => {}); // OAuth not configured - that's ok
|
|
2488
|
+
}, []);
|
|
2489
|
+
|
|
2490
|
+
// Social login handler
|
|
2491
|
+
const handleSocialLogin = async (provider: string) => {
|
|
2492
|
+
try {
|
|
2493
|
+
// Save cart ID before redirect
|
|
2494
|
+
const cartId = localStorage.getItem('cartId');
|
|
2495
|
+
if (cartId) sessionStorage.setItem('pendingCartId', cartId);
|
|
2496
|
+
|
|
2497
|
+
const { authorizationUrl, state } = await omni.getOAuthAuthorizeUrl(
|
|
2498
|
+
provider as 'GOOGLE' | 'FACEBOOK' | 'GITHUB',
|
|
2499
|
+
{ redirectUrl: window.location.origin + '/auth/callback' }
|
|
2500
|
+
);
|
|
2501
|
+
|
|
2502
|
+
sessionStorage.setItem('oauthState', state);
|
|
2503
|
+
window.location.href = authorizationUrl;
|
|
2504
|
+
} catch (err) {
|
|
2505
|
+
setError('Failed to start login');
|
|
2506
|
+
}
|
|
2507
|
+
};
|
|
2508
|
+
|
|
2509
|
+
// Regular email/password login
|
|
2510
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
2511
|
+
e.preventDefault();
|
|
2512
|
+
setLoading(true);
|
|
2513
|
+
setError('');
|
|
2514
|
+
try {
|
|
2515
|
+
const auth = await omni.loginCustomer(email, password);
|
|
2516
|
+
setCustomerToken(auth.token);
|
|
2517
|
+
const returnUrl = localStorage.getItem('returnUrl') || '/';
|
|
2518
|
+
localStorage.removeItem('returnUrl');
|
|
2519
|
+
window.location.href = returnUrl;
|
|
2520
|
+
} catch (err) {
|
|
2521
|
+
setError('Invalid email or password');
|
|
2522
|
+
} finally {
|
|
2523
|
+
setLoading(false);
|
|
2524
|
+
}
|
|
2525
|
+
};
|
|
2526
|
+
|
|
2527
|
+
return (
|
|
2528
|
+
<div className="max-w-md mx-auto mt-12">
|
|
2529
|
+
<h1 className="text-2xl font-bold mb-6">Login</h1>
|
|
2530
|
+
|
|
2531
|
+
{/* Social Login Buttons */}
|
|
2532
|
+
{providers.length > 0 && (
|
|
2533
|
+
<div className="space-y-3 mb-6">
|
|
2534
|
+
{providers.includes('GOOGLE') && (
|
|
2535
|
+
<button
|
|
2536
|
+
onClick={() => handleSocialLogin('GOOGLE')}
|
|
2537
|
+
className="w-full flex items-center justify-center gap-2 border py-3 rounded hover:bg-gray-50"
|
|
2538
|
+
>
|
|
2539
|
+
<GoogleIcon />
|
|
2540
|
+
Continue with Google
|
|
2541
|
+
</button>
|
|
2542
|
+
)}
|
|
2543
|
+
{providers.includes('FACEBOOK') && (
|
|
2544
|
+
<button
|
|
2545
|
+
onClick={() => handleSocialLogin('FACEBOOK')}
|
|
2546
|
+
className="w-full flex items-center justify-center gap-2 bg-[#1877F2] text-white py-3 rounded"
|
|
2547
|
+
>
|
|
2548
|
+
<FacebookIcon />
|
|
2549
|
+
Continue with Facebook
|
|
2550
|
+
</button>
|
|
2551
|
+
)}
|
|
2552
|
+
{providers.includes('GITHUB') && (
|
|
2553
|
+
<button
|
|
2554
|
+
onClick={() => handleSocialLogin('GITHUB')}
|
|
2555
|
+
className="w-full flex items-center justify-center gap-2 bg-[#24292F] text-white py-3 rounded"
|
|
2556
|
+
>
|
|
2557
|
+
<GithubIcon />
|
|
2558
|
+
Continue with GitHub
|
|
2559
|
+
</button>
|
|
2560
|
+
)}
|
|
2561
|
+
<div className="relative my-4">
|
|
2562
|
+
<div className="absolute inset-0 flex items-center">
|
|
2563
|
+
<div className="w-full border-t" />
|
|
2564
|
+
</div>
|
|
2565
|
+
<div className="relative flex justify-center text-sm">
|
|
2566
|
+
<span className="px-2 bg-white text-gray-500">or</span>
|
|
2567
|
+
</div>
|
|
2568
|
+
</div>
|
|
2569
|
+
</div>
|
|
2570
|
+
)}
|
|
2571
|
+
|
|
2572
|
+
{/* Email/Password Form */}
|
|
2573
|
+
{error && <div className="bg-red-100 text-red-600 p-3 rounded mb-4">{error}</div>}
|
|
2574
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
2575
|
+
<input
|
|
2576
|
+
type="email"
|
|
2577
|
+
placeholder="Email"
|
|
2578
|
+
value={email}
|
|
2579
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
2580
|
+
required
|
|
2581
|
+
className="w-full border p-2 rounded"
|
|
2582
|
+
/>
|
|
2583
|
+
<input
|
|
2584
|
+
type="password"
|
|
2585
|
+
placeholder="Password"
|
|
2586
|
+
value={password}
|
|
2587
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
2588
|
+
required
|
|
2589
|
+
className="w-full border p-2 rounded"
|
|
2590
|
+
/>
|
|
2591
|
+
<button
|
|
2592
|
+
type="submit"
|
|
2593
|
+
disabled={loading}
|
|
2594
|
+
className="w-full bg-black text-white py-3 rounded"
|
|
2595
|
+
>
|
|
2596
|
+
{loading ? 'Logging in...' : 'Login'}
|
|
2597
|
+
</button>
|
|
2598
|
+
</form>
|
|
2599
|
+
|
|
2600
|
+
<p className="mt-4 text-center">
|
|
2601
|
+
Don't have an account? <a href="/register" className="text-blue-600">Register</a>
|
|
2602
|
+
</p>
|
|
2603
|
+
</div>
|
|
2604
|
+
);
|
|
2605
|
+
}
|
|
2606
|
+
|
|
2607
|
+
// Simple SVG icons (or use lucide-react)
|
|
2608
|
+
const GoogleIcon = () => (
|
|
2609
|
+
<svg className="w-5 h-5" viewBox="0 0 24 24">
|
|
2610
|
+
<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"/>
|
|
2611
|
+
<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"/>
|
|
2612
|
+
<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"/>
|
|
2613
|
+
<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"/>
|
|
2614
|
+
</svg>
|
|
2615
|
+
);
|
|
2616
|
+
|
|
2617
|
+
const FacebookIcon = () => (
|
|
2618
|
+
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
|
2619
|
+
<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"/>
|
|
2620
|
+
</svg>
|
|
2621
|
+
);
|
|
2622
|
+
|
|
2623
|
+
const GithubIcon = () => (
|
|
2624
|
+
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
|
2625
|
+
<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"/>
|
|
2626
|
+
</svg>
|
|
2627
|
+
);
|
|
2628
|
+
```
|
|
2629
|
+
|
|
2630
|
+
#### Account Linking (Add Social Account to Existing User)
|
|
2631
|
+
|
|
2632
|
+
Logged-in customers can link additional social accounts to their profile:
|
|
2633
|
+
|
|
2634
|
+
```typescript
|
|
2635
|
+
// Get currently linked accounts
|
|
2636
|
+
const connections = await omni.getOAuthConnections();
|
|
2637
|
+
// [{ provider: 'GOOGLE', email: 'user@gmail.com', linkedAt: '...' }]
|
|
2638
|
+
|
|
2639
|
+
// Link a new provider (redirects to OAuth flow)
|
|
2640
|
+
const { authorizationUrl } = await omni.linkOAuthProvider('GITHUB');
|
|
2641
|
+
window.location.href = authorizationUrl;
|
|
2642
|
+
|
|
2643
|
+
// Unlink a provider
|
|
2644
|
+
await omni.unlinkOAuthProvider('GOOGLE');
|
|
2645
|
+
```
|
|
2646
|
+
|
|
2647
|
+
#### Cart Linking After OAuth Login
|
|
2648
|
+
|
|
2649
|
+
When a customer logs in via OAuth, their guest cart should be linked to their account:
|
|
2650
|
+
|
|
2651
|
+
```typescript
|
|
2652
|
+
// omni.linkCart() associates a guest cart with the logged-in customer
|
|
2653
|
+
await omni.linkCart(cartId);
|
|
2654
|
+
```
|
|
2655
|
+
|
|
2656
|
+
This is automatically handled in the callback example above.
|
|
2657
|
+
|
|
2658
|
+
#### OAuth Method Reference
|
|
2659
|
+
|
|
2660
|
+
| Method | Description |
|
|
2661
|
+
| -------------------------------------------- | -------------------------------------------- |
|
|
2662
|
+
| `getAvailableOAuthProviders()` | Get list of enabled providers for this store |
|
|
2663
|
+
| `getOAuthAuthorizeUrl(provider, options?)` | Get URL to redirect user to OAuth provider |
|
|
2664
|
+
| `handleOAuthCallback(provider, code, state)` | Exchange OAuth code for customer token |
|
|
2665
|
+
| `linkOAuthProvider(provider)` | Link social account to current customer |
|
|
2666
|
+
| `unlinkOAuthProvider(provider)` | Remove linked social account |
|
|
2667
|
+
| `getOAuthConnections()` | Get list of linked social accounts |
|
|
2668
|
+
| `linkCart(cartId)` | Link guest cart to logged-in customer |
|
|
2669
|
+
|
|
2670
|
+
---
|
|
2671
|
+
|
|
2672
|
+
### Customer Addresses
|
|
2673
|
+
|
|
2674
|
+
#### Get Addresses
|
|
2675
|
+
|
|
2676
|
+
```typescript
|
|
2677
|
+
const addresses = await omni.getMyAddresses();
|
|
2678
|
+
```
|
|
2679
|
+
|
|
2680
|
+
#### Add Address
|
|
2681
|
+
|
|
2682
|
+
```typescript
|
|
2683
|
+
const address = await omni.addMyAddress({
|
|
2684
|
+
firstName: 'John',
|
|
2685
|
+
lastName: 'Doe',
|
|
2686
|
+
line1: '123 Main St',
|
|
2687
|
+
city: 'New York',
|
|
2688
|
+
region: 'NY',
|
|
2689
|
+
postalCode: '10001',
|
|
2690
|
+
country: 'US',
|
|
2691
|
+
isDefault: true,
|
|
2692
|
+
});
|
|
2693
|
+
```
|
|
2694
|
+
|
|
2695
|
+
#### Update Address
|
|
2696
|
+
|
|
2697
|
+
```typescript
|
|
2698
|
+
const updated = await omni.updateMyAddress(addressId, {
|
|
2699
|
+
line1: '456 New Street',
|
|
2700
|
+
});
|
|
2701
|
+
```
|
|
2702
|
+
|
|
2703
|
+
#### Delete Address
|
|
2704
|
+
|
|
2705
|
+
```typescript
|
|
2706
|
+
await omni.deleteMyAddress(addressId);
|
|
2707
|
+
```
|
|
2708
|
+
|
|
2709
|
+
---
|
|
2710
|
+
|
|
2711
|
+
### Store Info
|
|
2712
|
+
|
|
2713
|
+
```typescript
|
|
2714
|
+
const store = await omni.getStoreInfo();
|
|
2715
|
+
|
|
2716
|
+
console.log(store.name); // Store name
|
|
2717
|
+
console.log(store.currency); // 'USD', 'ILS', etc.
|
|
2718
|
+
console.log(store.language); // 'en', 'he', etc.
|
|
2719
|
+
```
|
|
2720
|
+
|
|
2721
|
+
---
|
|
2722
|
+
|
|
2723
|
+
## Admin API Reference
|
|
2724
|
+
|
|
2725
|
+
The Admin API requires an API key (`apiKey`) and provides full access to store configuration and management features.
|
|
2726
|
+
|
|
2727
|
+
```typescript
|
|
2728
|
+
// Initialize in Admin mode
|
|
2729
|
+
const omni = new BrainerceClient({
|
|
2730
|
+
apiKey: process.env.OMNI_API_KEY, // 'omni_*' prefix
|
|
2731
|
+
});
|
|
2732
|
+
```
|
|
2733
|
+
|
|
2734
|
+
### Taxonomy Management
|
|
2735
|
+
|
|
2736
|
+
```typescript
|
|
2737
|
+
// Categories
|
|
2738
|
+
const categories = await omni.listCategories({ page: 1, limit: 20 });
|
|
2739
|
+
const category = await omni.getCategory('cat_id');
|
|
2740
|
+
const newCategory = await omni.createCategory({ name: 'Electronics', slug: 'electronics' });
|
|
2741
|
+
await omni.updateCategory('cat_id', { name: 'Updated Name' });
|
|
2742
|
+
await omni.deleteCategory('cat_id');
|
|
2743
|
+
|
|
2744
|
+
// Brands
|
|
2745
|
+
const brands = await omni.listBrands();
|
|
2746
|
+
const brand = await omni.createBrand({ name: 'Nike', slug: 'nike' });
|
|
2747
|
+
|
|
2748
|
+
// Tags
|
|
2749
|
+
const tags = await omni.listTags();
|
|
2750
|
+
const tag = await omni.createTag({ name: 'Sale', slug: 'sale' });
|
|
2751
|
+
|
|
2752
|
+
// Attributes (for variant options)
|
|
2753
|
+
const attributes = await omni.listAttributes();
|
|
2754
|
+
const attribute = await omni.createAttribute({ name: 'Color', slug: 'color' });
|
|
2755
|
+
const options = await omni.getAttributeOptions('attr_id');
|
|
2756
|
+
await omni.createAttributeOption('attr_id', { value: 'Red', slug: 'red' });
|
|
2757
|
+
```
|
|
2758
|
+
|
|
2759
|
+
### Shipping Configuration
|
|
2760
|
+
|
|
2761
|
+
```typescript
|
|
2762
|
+
// Shipping Zones
|
|
2763
|
+
const zones = await omni.listShippingZones();
|
|
2764
|
+
const zone = await omni.createShippingZone({
|
|
2765
|
+
name: 'US Domestic',
|
|
2766
|
+
countries: ['US'],
|
|
2767
|
+
});
|
|
2768
|
+
|
|
2769
|
+
// Shipping Rates
|
|
2770
|
+
const rates = await omni.getZoneShippingRates('zone_id');
|
|
2771
|
+
await omni.createZoneShippingRate('zone_id', {
|
|
2772
|
+
name: 'Standard Shipping',
|
|
2773
|
+
type: 'flat',
|
|
2774
|
+
price: 5.99,
|
|
2775
|
+
estimatedDays: '3-5',
|
|
2776
|
+
});
|
|
2777
|
+
```
|
|
2778
|
+
|
|
2779
|
+
### Tax Configuration
|
|
2780
|
+
|
|
2781
|
+
```typescript
|
|
2782
|
+
const rates = await omni.getTaxRates();
|
|
2783
|
+
await omni.createTaxRate({
|
|
2784
|
+
name: 'CA Sales Tax',
|
|
2785
|
+
rate: 7.25,
|
|
2786
|
+
country: 'US',
|
|
2787
|
+
region: 'CA',
|
|
2788
|
+
});
|
|
2789
|
+
```
|
|
2790
|
+
|
|
2791
|
+
### Metafield Definitions
|
|
2792
|
+
|
|
2793
|
+
```typescript
|
|
2794
|
+
// Define custom fields
|
|
2795
|
+
const definitions = await omni.getMetafieldDefinitions();
|
|
2796
|
+
await omni.createMetafieldDefinition({
|
|
2797
|
+
name: 'Care Instructions',
|
|
2798
|
+
key: 'care_instructions',
|
|
2799
|
+
type: 'multi_line_text',
|
|
2800
|
+
});
|
|
2801
|
+
|
|
2802
|
+
// Set values on products
|
|
2803
|
+
await omni.setProductMetafield('prod_id', 'def_id', {
|
|
2804
|
+
value: 'Machine wash cold',
|
|
2805
|
+
});
|
|
2806
|
+
```
|
|
2807
|
+
|
|
2808
|
+
### Store Team Management
|
|
2809
|
+
|
|
2810
|
+
Each store has its own team with roles (`OWNER`, `MANAGER`, `STAFF`, `VIEWER`) and granular permissions.
|
|
2811
|
+
|
|
2812
|
+
```typescript
|
|
2813
|
+
// List team members + pending invitations for a store
|
|
2814
|
+
const { members, invitations } = await omni.getStoreTeam('store_id');
|
|
2815
|
+
|
|
2816
|
+
// Invite a new member
|
|
2817
|
+
const invitation = await omni.inviteStoreMember('store_id', {
|
|
2818
|
+
email: 'newmember@example.com',
|
|
2819
|
+
role: 'MANAGER', // 'MANAGER' | 'STAFF' | 'VIEWER'
|
|
2820
|
+
});
|
|
2821
|
+
|
|
2822
|
+
// Update member role or set custom permissions
|
|
2823
|
+
await omni.updateStoreMember('store_id', 'member_id', { role: 'STAFF' });
|
|
2824
|
+
await omni.updateStoreMember('store_id', 'member_id', {
|
|
2825
|
+
permissions: ['VIEW_PRODUCTS', 'VIEW_ORDERS', 'FULFILL_ORDERS'], // overrides role defaults
|
|
2826
|
+
});
|
|
2827
|
+
|
|
2828
|
+
// Remove a member
|
|
2829
|
+
await omni.removeStoreMember('store_id', 'member_id');
|
|
2830
|
+
|
|
2831
|
+
// Manage invitations
|
|
2832
|
+
await omni.resendStoreInvitation('store_id', 'inv_id');
|
|
2833
|
+
await omni.revokeStoreInvitation('store_id', 'inv_id');
|
|
2834
|
+
|
|
2835
|
+
// Get all stores accessible to the current user (owned + shared)
|
|
2836
|
+
const stores = await omni.getMyStores();
|
|
2837
|
+
// [{ id, name, role: 'OWNER', context: 'owner' }, { id, name, role: 'MANAGER', context: 'member' }]
|
|
2838
|
+
|
|
2839
|
+
// Get user's permissions for a specific store
|
|
2840
|
+
const { role, permissions } = await omni.getMyStorePermissions('store_id');
|
|
2841
|
+
```
|
|
2842
|
+
|
|
2843
|
+
> **Note:** The previous account-level team methods (`getTeamMembers`, `inviteTeamMember`, etc.) are deprecated. Use the store-level methods above instead.
|
|
2844
|
+
|
|
2845
|
+
### Email Settings & Templates
|
|
2846
|
+
|
|
2847
|
+
```typescript
|
|
2848
|
+
// Get/update settings
|
|
2849
|
+
const settings = await omni.getEmailSettings();
|
|
2850
|
+
await omni.updateEmailSettings({
|
|
2851
|
+
emailsEnabled: true,
|
|
2852
|
+
defaultFromName: 'My Store',
|
|
2853
|
+
eventSettings: {
|
|
2854
|
+
ORDER_CONFIRMATION: { enabled: true, sendToCustomer: true },
|
|
2855
|
+
},
|
|
2856
|
+
});
|
|
2857
|
+
|
|
2858
|
+
// Manage templates
|
|
2859
|
+
const templates = await omni.getEmailTemplates();
|
|
2860
|
+
await omni.createEmailTemplate({
|
|
2861
|
+
name: 'Order Confirmation',
|
|
2862
|
+
eventType: 'ORDER_CONFIRMATION',
|
|
2863
|
+
subject: 'Your order #{{orderNumber}}',
|
|
2864
|
+
htmlContent: '<h1>Thank you!</h1>...',
|
|
2865
|
+
});
|
|
2866
|
+
|
|
2867
|
+
// Preview template
|
|
2868
|
+
const preview = await omni.previewEmailTemplate('template_id', {
|
|
2869
|
+
variables: { orderNumber: '1001' },
|
|
2870
|
+
});
|
|
2871
|
+
```
|
|
2872
|
+
|
|
2873
|
+
### Sync Conflict Resolution
|
|
2874
|
+
|
|
2875
|
+
```typescript
|
|
2876
|
+
// Product sync conflicts
|
|
2877
|
+
const conflicts = await omni.getSyncConflicts();
|
|
2878
|
+
await omni.resolveSyncConflict('conflict_id', 'MERGE'); // or 'CREATE_NEW'
|
|
2879
|
+
|
|
2880
|
+
// Metafield conflicts
|
|
2881
|
+
const metafieldConflicts = await omni.getMetafieldConflicts();
|
|
2882
|
+
await omni.resolveMetafieldConflict('conflict_id', {
|
|
2883
|
+
resolution: 'USE_INCOMING', // 'KEEP_EXISTING' | 'USE_INCOMING' | 'MERGE'
|
|
2884
|
+
});
|
|
2885
|
+
await omni.ignoreMetafieldConflict('conflict_id');
|
|
2886
|
+
```
|
|
2887
|
+
|
|
2888
|
+
### OAuth Provider Configuration
|
|
2889
|
+
|
|
2890
|
+
```typescript
|
|
2891
|
+
// Configure social login for customers
|
|
2892
|
+
const providers = await omni.getOAuthProviders();
|
|
2893
|
+
await omni.configureOAuthProvider({
|
|
2894
|
+
provider: 'GOOGLE',
|
|
2895
|
+
clientId: 'your-google-client-id',
|
|
2896
|
+
clientSecret: 'your-google-client-secret',
|
|
2897
|
+
isEnabled: true,
|
|
2898
|
+
});
|
|
2899
|
+
await omni.updateOAuthProvider('GOOGLE', { isEnabled: false });
|
|
2900
|
+
await omni.deleteOAuthProvider('GOOGLE');
|
|
2901
|
+
```
|
|
2902
|
+
|
|
2903
|
+
---
|
|
2904
|
+
|
|
2905
|
+
## Complete Page Examples
|
|
2906
|
+
|
|
2907
|
+
### Home Page
|
|
2908
|
+
|
|
2909
|
+
```typescript
|
|
2910
|
+
'use client';
|
|
2911
|
+
import { useEffect, useState } from 'react';
|
|
2912
|
+
import { omni } from '@/lib/brainerce';
|
|
2913
|
+
import type { Product } from 'brainerce';
|
|
2914
|
+
|
|
2915
|
+
export default function HomePage() {
|
|
2916
|
+
const [products, setProducts] = useState<Product[]>([]);
|
|
2917
|
+
const [loading, setLoading] = useState(true);
|
|
2918
|
+
const [error, setError] = useState<string | null>(null);
|
|
2919
|
+
|
|
2920
|
+
useEffect(() => {
|
|
2921
|
+
async function loadProducts() {
|
|
2922
|
+
try {
|
|
2923
|
+
const { data } = await omni.getProducts({ limit: 8 });
|
|
2924
|
+
setProducts(data);
|
|
2925
|
+
} catch (err) {
|
|
2926
|
+
setError('Failed to load products');
|
|
2927
|
+
} finally {
|
|
2928
|
+
setLoading(false);
|
|
2929
|
+
}
|
|
2930
|
+
}
|
|
2931
|
+
loadProducts();
|
|
2932
|
+
}, []);
|
|
2933
|
+
|
|
2934
|
+
if (loading) return <div>Loading...</div>;
|
|
2935
|
+
if (error) return <div>{error}</div>;
|
|
2936
|
+
|
|
2937
|
+
return (
|
|
2938
|
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
|
2939
|
+
{products.map((product) => (
|
|
2940
|
+
<a key={product.id} href={`/products/${product.slug}`} className="group">
|
|
2941
|
+
<img
|
|
2942
|
+
src={product.images?.[0]?.url || '/placeholder.jpg'}
|
|
2943
|
+
alt={product.name}
|
|
2944
|
+
className="w-full aspect-square object-cover"
|
|
2945
|
+
/>
|
|
2946
|
+
<h3 className="mt-2 font-medium">{product.name}</h3>
|
|
2947
|
+
<p className="text-lg">
|
|
2948
|
+
{product.salePrice ? (
|
|
2949
|
+
<>
|
|
2950
|
+
<span className="text-red-600">${product.salePrice}</span>
|
|
2951
|
+
<span className="line-through text-gray-400 ml-2">${product.basePrice}</span>
|
|
2952
|
+
</>
|
|
2953
|
+
) : (
|
|
2954
|
+
<span>${product.basePrice}</span>
|
|
2955
|
+
)}
|
|
2956
|
+
</p>
|
|
2957
|
+
</a>
|
|
2958
|
+
))}
|
|
2959
|
+
</div>
|
|
2960
|
+
);
|
|
2961
|
+
}
|
|
2962
|
+
```
|
|
2963
|
+
|
|
2964
|
+
### Products List with Pagination
|
|
2965
|
+
|
|
2966
|
+
```typescript
|
|
2967
|
+
'use client';
|
|
2968
|
+
import { useEffect, useState } from 'react';
|
|
2969
|
+
import { omni } from '@/lib/brainerce';
|
|
2970
|
+
import type { Product, PaginatedResponse } from 'brainerce';
|
|
2971
|
+
|
|
2972
|
+
export default function ProductsPage() {
|
|
2973
|
+
const [data, setData] = useState<PaginatedResponse<Product> | null>(null);
|
|
2974
|
+
const [page, setPage] = useState(1);
|
|
2975
|
+
const [loading, setLoading] = useState(true);
|
|
2976
|
+
|
|
2977
|
+
useEffect(() => {
|
|
2978
|
+
async function load() {
|
|
2979
|
+
setLoading(true);
|
|
2980
|
+
try {
|
|
2981
|
+
const result = await omni.getProducts({ page, limit: 12 });
|
|
2982
|
+
setData(result);
|
|
2983
|
+
} finally {
|
|
2984
|
+
setLoading(false);
|
|
2985
|
+
}
|
|
2986
|
+
}
|
|
2987
|
+
load();
|
|
2988
|
+
}, [page]);
|
|
2989
|
+
|
|
2990
|
+
if (loading) return <div>Loading...</div>;
|
|
2991
|
+
if (!data) return <div>No products found</div>;
|
|
2992
|
+
|
|
2993
|
+
return (
|
|
2994
|
+
<div>
|
|
2995
|
+
<div className="grid grid-cols-3 gap-6">
|
|
2996
|
+
{data.data.map((product) => (
|
|
2997
|
+
<a key={product.id} href={`/products/${product.slug}`}>
|
|
2998
|
+
<img src={product.images?.[0]?.url} alt={product.name} />
|
|
2999
|
+
<h3>{product.name}</h3>
|
|
3000
|
+
<p>${product.salePrice || product.basePrice}</p>
|
|
3001
|
+
</a>
|
|
3002
|
+
))}
|
|
3003
|
+
</div>
|
|
3004
|
+
|
|
3005
|
+
{/* Pagination */}
|
|
3006
|
+
<div className="flex gap-2 mt-8">
|
|
3007
|
+
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1}>
|
|
3008
|
+
Previous
|
|
3009
|
+
</button>
|
|
3010
|
+
<span>Page {data.meta.page} of {data.meta.totalPages}</span>
|
|
3011
|
+
<button onClick={() => setPage(p => p + 1)} disabled={page >= data.meta.totalPages}>
|
|
3012
|
+
Next
|
|
3013
|
+
</button>
|
|
3014
|
+
</div>
|
|
3015
|
+
</div>
|
|
3016
|
+
);
|
|
3017
|
+
}
|
|
3018
|
+
```
|
|
3019
|
+
|
|
3020
|
+
### Product Detail with Add to Cart (Local Cart)
|
|
3021
|
+
|
|
3022
|
+
```typescript
|
|
3023
|
+
'use client';
|
|
3024
|
+
import { useEffect, useState } from 'react';
|
|
3025
|
+
import { omni } from '@/lib/brainerce';
|
|
3026
|
+
import { isHtmlDescription, getVariantPrice, getVariantOptions, formatPrice } from 'brainerce';
|
|
3027
|
+
import type { Product, ProductVariant, StoreInfo } from 'brainerce';
|
|
3028
|
+
|
|
3029
|
+
// Route: /products/[slug]/page.tsx - uses URL-friendly slug instead of ID
|
|
3030
|
+
export default function ProductPage({ params }: { params: { slug: string } }) {
|
|
3031
|
+
const [product, setProduct] = useState<Product | null>(null);
|
|
3032
|
+
const [selectedVariant, setSelectedVariant] = useState<ProductVariant | null>(null);
|
|
3033
|
+
const [storeInfo, setStoreInfo] = useState<StoreInfo | null>(null);
|
|
3034
|
+
const [quantity, setQuantity] = useState(1);
|
|
3035
|
+
const [loading, setLoading] = useState(true);
|
|
3036
|
+
|
|
3037
|
+
useEffect(() => {
|
|
3038
|
+
async function load() {
|
|
3039
|
+
try {
|
|
3040
|
+
const [p, info] = await Promise.all([
|
|
3041
|
+
omni.getProductBySlug(params.slug),
|
|
3042
|
+
omni.getStoreInfo(),
|
|
3043
|
+
]);
|
|
3044
|
+
setProduct(p);
|
|
3045
|
+
setStoreInfo(info);
|
|
3046
|
+
// Auto-select first variant for VARIABLE products
|
|
3047
|
+
if (p.type === 'VARIABLE' && p.variants && p.variants.length > 0) {
|
|
3048
|
+
setSelectedVariant(p.variants[0]);
|
|
3049
|
+
}
|
|
3050
|
+
} finally {
|
|
3051
|
+
setLoading(false);
|
|
3052
|
+
}
|
|
3053
|
+
}
|
|
3054
|
+
load();
|
|
3055
|
+
}, [params.slug]);
|
|
3056
|
+
|
|
3057
|
+
// Get the image to display (variant image takes priority)
|
|
3058
|
+
const getDisplayImage = () => {
|
|
3059
|
+
// Use variant-specific image if available
|
|
3060
|
+
if (selectedVariant?.image) {
|
|
3061
|
+
const img = selectedVariant.image;
|
|
3062
|
+
return typeof img === 'string' ? img : img.url;
|
|
3063
|
+
}
|
|
3064
|
+
// Fall back to product's main image
|
|
3065
|
+
return product?.images?.[0]?.url || '/placeholder.jpg';
|
|
3066
|
+
};
|
|
3067
|
+
|
|
3068
|
+
// Get the price to display (variant price takes priority)
|
|
3069
|
+
const getDisplayPrice = () => {
|
|
3070
|
+
if (!product) return '0';
|
|
3071
|
+
if (selectedVariant) {
|
|
3072
|
+
return getVariantPrice(selectedVariant, product.basePrice).toString();
|
|
3073
|
+
}
|
|
3074
|
+
return product.salePrice || product.basePrice;
|
|
3075
|
+
};
|
|
3076
|
+
|
|
3077
|
+
const handleAddToCart = () => {
|
|
3078
|
+
if (!product) return;
|
|
3079
|
+
|
|
3080
|
+
// Add to local cart (NO API call!)
|
|
3081
|
+
omni.addToLocalCart({
|
|
3082
|
+
productId: product.id,
|
|
3083
|
+
variantId: selectedVariant?.id,
|
|
3084
|
+
quantity,
|
|
3085
|
+
name: selectedVariant?.name
|
|
3086
|
+
? `${product.name} - ${selectedVariant.name}`
|
|
3087
|
+
: product.name,
|
|
3088
|
+
price: getDisplayPrice(),
|
|
3089
|
+
image: getDisplayImage(),
|
|
3090
|
+
});
|
|
3091
|
+
|
|
3092
|
+
alert('Added to cart!');
|
|
3093
|
+
};
|
|
3094
|
+
|
|
3095
|
+
if (loading) return <div>Loading...</div>;
|
|
3096
|
+
if (!product) return <div>Product not found</div>;
|
|
3097
|
+
|
|
3098
|
+
return (
|
|
3099
|
+
<div className="grid grid-cols-2 gap-8">
|
|
3100
|
+
{/* Images - updates when variant changes */}
|
|
3101
|
+
<div>
|
|
3102
|
+
<img
|
|
3103
|
+
src={getDisplayImage()}
|
|
3104
|
+
alt={product.name}
|
|
3105
|
+
className="w-full"
|
|
3106
|
+
/>
|
|
3107
|
+
</div>
|
|
3108
|
+
|
|
3109
|
+
{/* Details */}
|
|
3110
|
+
<div>
|
|
3111
|
+
<h1 className="text-3xl font-bold">{product.name}</h1>
|
|
3112
|
+
{/* Price updates based on selected variant */}
|
|
3113
|
+
<p className="text-2xl mt-4">
|
|
3114
|
+
{formatPrice(getDisplayPrice(), { currency: storeInfo?.currency || 'USD' })}
|
|
3115
|
+
</p>
|
|
3116
|
+
|
|
3117
|
+
{/* IMPORTANT: Use isHtmlDescription() to render HTML descriptions correctly */}
|
|
3118
|
+
{product.description && (
|
|
3119
|
+
isHtmlDescription(product) ? (
|
|
3120
|
+
<div className="mt-4 text-gray-600" dangerouslySetInnerHTML={{ __html: product.description }} />
|
|
3121
|
+
) : (
|
|
3122
|
+
<p className="mt-4 text-gray-600">{product.description}</p>
|
|
3123
|
+
)
|
|
3124
|
+
)}
|
|
3125
|
+
|
|
3126
|
+
{/* Variant Selection - shows attribute-based buttons */}
|
|
3127
|
+
{product.type === 'VARIABLE' && product.variants && product.variants.length > 0 && (
|
|
3128
|
+
<div className="mt-6">
|
|
3129
|
+
{/* Extract unique attribute names from all variants */}
|
|
3130
|
+
{(() => {
|
|
3131
|
+
const allOptions = product.variants.map(v => getVariantOptions(v));
|
|
3132
|
+
const attributeNames = [...new Set(allOptions.flatMap(opts => opts.map(o => o.name)))];
|
|
3133
|
+
|
|
3134
|
+
return attributeNames.map(attrName => {
|
|
3135
|
+
const uniqueValues = [...new Set(
|
|
3136
|
+
allOptions.flatMap(opts => opts.filter(o => o.name === attrName).map(o => o.value))
|
|
3137
|
+
)];
|
|
3138
|
+
const currentValue = getVariantOptions(selectedVariant).find(o => o.name === attrName)?.value;
|
|
3139
|
+
|
|
3140
|
+
return (
|
|
3141
|
+
<div key={attrName} className="mb-4">
|
|
3142
|
+
<label className="block font-medium mb-2">
|
|
3143
|
+
{attrName}: {currentValue}
|
|
3144
|
+
</label>
|
|
3145
|
+
<div className="flex gap-2">
|
|
3146
|
+
{uniqueValues.map(value => (
|
|
3147
|
+
<button
|
|
3148
|
+
key={value}
|
|
3149
|
+
onClick={() => {
|
|
3150
|
+
// Find variant matching the selected attribute value
|
|
3151
|
+
const match = product.variants?.find(v => {
|
|
3152
|
+
const opts = getVariantOptions(v);
|
|
3153
|
+
return opts.some(o => o.name === attrName && o.value === value);
|
|
3154
|
+
});
|
|
3155
|
+
if (match) setSelectedVariant(match);
|
|
3156
|
+
}}
|
|
3157
|
+
className={`px-4 py-2 border rounded ${
|
|
3158
|
+
currentValue === value
|
|
3159
|
+
? 'bg-black text-white border-black'
|
|
3160
|
+
: 'bg-white text-black border-gray-300 hover:border-black'
|
|
3161
|
+
}`}
|
|
3162
|
+
>
|
|
3163
|
+
{value}
|
|
3164
|
+
</button>
|
|
3165
|
+
))}
|
|
3166
|
+
</div>
|
|
3167
|
+
</div>
|
|
3168
|
+
);
|
|
3169
|
+
});
|
|
3170
|
+
})()}
|
|
3171
|
+
</div>
|
|
3172
|
+
)}
|
|
3173
|
+
|
|
3174
|
+
{/* Quantity */}
|
|
3175
|
+
<div className="mt-4">
|
|
3176
|
+
<label className="block font-medium mb-2">Quantity</label>
|
|
3177
|
+
<input
|
|
3178
|
+
type="number"
|
|
3179
|
+
min="1"
|
|
3180
|
+
value={quantity}
|
|
3181
|
+
onChange={(e) => setQuantity(Number(e.target.value))}
|
|
3182
|
+
className="border rounded p-2 w-20"
|
|
3183
|
+
/>
|
|
3184
|
+
</div>
|
|
3185
|
+
|
|
3186
|
+
{/* Add to Cart Button */}
|
|
3187
|
+
<button
|
|
3188
|
+
onClick={handleAddToCart}
|
|
3189
|
+
disabled={adding}
|
|
3190
|
+
className="mt-6 w-full bg-black text-white py-3 rounded disabled:opacity-50"
|
|
3191
|
+
>
|
|
3192
|
+
{adding ? 'Adding...' : 'Add to Cart'}
|
|
3193
|
+
</button>
|
|
3194
|
+
|
|
3195
|
+
{/* Stock Status - use inStock which handles UNLIMITED variants */}
|
|
3196
|
+
{product.inventory && (
|
|
3197
|
+
<p className="mt-4 text-sm">
|
|
3198
|
+
{product.inventory.inStock
|
|
3199
|
+
? (product.inventory.trackingMode === 'UNLIMITED'
|
|
3200
|
+
? 'In Stock'
|
|
3201
|
+
: `${product.inventory.available} in stock`)
|
|
3202
|
+
: 'Out of stock'}
|
|
3203
|
+
</p>
|
|
3204
|
+
)}
|
|
3205
|
+
</div>
|
|
3206
|
+
</div>
|
|
3207
|
+
);
|
|
3208
|
+
}
|
|
3209
|
+
```
|
|
3210
|
+
|
|
3211
|
+
### Cart Page (Local Cart)
|
|
3212
|
+
|
|
3213
|
+
```typescript
|
|
3214
|
+
'use client';
|
|
3215
|
+
import { useState, useEffect } from 'react';
|
|
3216
|
+
import { omni } from '@/lib/brainerce';
|
|
3217
|
+
import type { LocalCart } from 'brainerce';
|
|
3218
|
+
|
|
3219
|
+
export default function CartPage() {
|
|
3220
|
+
// ⚠️ Do NOT use useState(omni.getLocalCart()) — causes hydration mismatch!
|
|
3221
|
+
// Server has no localStorage (empty cart) but client does (cart with items).
|
|
3222
|
+
const [cart, setCart] = useState<LocalCart>({ items: [], updatedAt: '' });
|
|
3223
|
+
|
|
3224
|
+
useEffect(() => {
|
|
3225
|
+
setCart(omni.getLocalCart());
|
|
3226
|
+
}, []);
|
|
3227
|
+
|
|
3228
|
+
const updateQuantity = (productId: string, quantity: number, variantId?: string) => {
|
|
3229
|
+
const updated = omni.updateLocalCartItem(productId, quantity, variantId);
|
|
3230
|
+
setCart(updated);
|
|
3231
|
+
};
|
|
3232
|
+
|
|
3233
|
+
const removeItem = (productId: string, variantId?: string) => {
|
|
3234
|
+
const updated = omni.removeFromLocalCart(productId, variantId);
|
|
3235
|
+
setCart(updated);
|
|
3236
|
+
};
|
|
3237
|
+
|
|
3238
|
+
// Calculate subtotal from local cart
|
|
3239
|
+
const subtotal = cart.items.reduce((sum, item) => {
|
|
3240
|
+
return sum + (parseFloat(item.price || '0') * item.quantity);
|
|
3241
|
+
}, 0);
|
|
3242
|
+
|
|
3243
|
+
if (cart.items.length === 0) {
|
|
3244
|
+
return (
|
|
3245
|
+
<div className="text-center py-12">
|
|
3246
|
+
<h1 className="text-2xl font-bold">Your cart is empty</h1>
|
|
3247
|
+
<a href="/products" className="text-blue-600 mt-4 inline-block">Continue Shopping</a>
|
|
3248
|
+
</div>
|
|
3249
|
+
);
|
|
3250
|
+
}
|
|
3251
|
+
|
|
3252
|
+
return (
|
|
3253
|
+
<div>
|
|
3254
|
+
<h1 className="text-2xl font-bold mb-6">Shopping Cart</h1>
|
|
3255
|
+
|
|
3256
|
+
{cart.items.map((item) => (
|
|
3257
|
+
<div key={`${item.productId}-${item.variantId || ''}`} className="flex items-center gap-4 py-4 border-b">
|
|
3258
|
+
<img
|
|
3259
|
+
src={item.image || '/placeholder.jpg'}
|
|
3260
|
+
alt={item.name || 'Product'}
|
|
3261
|
+
className="w-20 h-20 object-cover"
|
|
3262
|
+
/>
|
|
3263
|
+
<div className="flex-1">
|
|
3264
|
+
<h3 className="font-medium">{item.name || 'Product'}</h3>
|
|
3265
|
+
<p className="font-bold">${item.price}</p>
|
|
3266
|
+
</div>
|
|
3267
|
+
<div className="flex items-center gap-2">
|
|
3268
|
+
<button
|
|
3269
|
+
onClick={() => updateQuantity(item.productId, item.quantity - 1, item.variantId)}
|
|
3270
|
+
className="w-8 h-8 border rounded"
|
|
3271
|
+
>-</button>
|
|
3272
|
+
<span className="w-8 text-center">{item.quantity}</span>
|
|
3273
|
+
<button
|
|
3274
|
+
onClick={() => updateQuantity(item.productId, item.quantity + 1, item.variantId)}
|
|
3275
|
+
className="w-8 h-8 border rounded"
|
|
3276
|
+
>+</button>
|
|
3277
|
+
</div>
|
|
3278
|
+
<button
|
|
3279
|
+
onClick={() => removeItem(item.productId, item.variantId)}
|
|
3280
|
+
className="text-red-600"
|
|
3281
|
+
>Remove</button>
|
|
3282
|
+
</div>
|
|
3283
|
+
))}
|
|
3284
|
+
|
|
3285
|
+
<div className="mt-6 text-right">
|
|
3286
|
+
<p className="text-xl">Subtotal: <strong>${subtotal.toFixed(2)}</strong></p>
|
|
3287
|
+
{cart.couponCode && (
|
|
3288
|
+
<p className="text-green-600">Coupon applied: {cart.couponCode}</p>
|
|
3289
|
+
)}
|
|
3290
|
+
<a
|
|
3291
|
+
href="/checkout"
|
|
3292
|
+
className="mt-4 inline-block bg-black text-white px-8 py-3 rounded"
|
|
3293
|
+
>
|
|
3294
|
+
Proceed to Checkout
|
|
3295
|
+
</a>
|
|
3296
|
+
</div>
|
|
3297
|
+
</div>
|
|
3298
|
+
);
|
|
3299
|
+
}
|
|
3300
|
+
```
|
|
3301
|
+
|
|
3302
|
+
### Universal Checkout (Handles Both Guest & Logged-In)
|
|
3303
|
+
|
|
3304
|
+
> **RECOMMENDED:** Use this pattern to properly handle both guest and logged-in customers in a single checkout page.
|
|
3305
|
+
|
|
3306
|
+
```typescript
|
|
3307
|
+
'use client';
|
|
3308
|
+
import { useState, useEffect } from 'react';
|
|
3309
|
+
import { omni, isLoggedIn, getServerCartId, setServerCartId, restoreCustomerToken } from '@/lib/brainerce';
|
|
3310
|
+
|
|
3311
|
+
export default function CheckoutPage() {
|
|
3312
|
+
const [loading, setLoading] = useState(true);
|
|
3313
|
+
const [submitting, setSubmitting] = useState(false);
|
|
3314
|
+
const [customerLoggedIn, setCustomerLoggedIn] = useState(false);
|
|
3315
|
+
|
|
3316
|
+
// Form state
|
|
3317
|
+
const [email, setEmail] = useState('');
|
|
3318
|
+
const [shippingAddress, setShippingAddress] = useState({
|
|
3319
|
+
firstName: '', lastName: '', line1: '', city: '', postalCode: '', country: 'US'
|
|
3320
|
+
});
|
|
3321
|
+
|
|
3322
|
+
// Server checkout state (for logged-in customers)
|
|
3323
|
+
const [checkoutId, setCheckoutId] = useState<string | null>(null);
|
|
3324
|
+
const [shippingRates, setShippingRates] = useState<any[]>([]);
|
|
3325
|
+
const [selectedRate, setSelectedRate] = useState<string | null>(null);
|
|
3326
|
+
|
|
3327
|
+
useEffect(() => {
|
|
3328
|
+
restoreCustomerToken();
|
|
3329
|
+
const loggedIn = isLoggedIn();
|
|
3330
|
+
setCustomerLoggedIn(loggedIn);
|
|
3331
|
+
|
|
3332
|
+
async function initCheckout() {
|
|
3333
|
+
if (loggedIn) {
|
|
3334
|
+
// Logged-in customer: Create server cart + checkout
|
|
3335
|
+
let cartId = getServerCartId();
|
|
3336
|
+
|
|
3337
|
+
if (!cartId) {
|
|
3338
|
+
// Create new cart (auto-linked to customer)
|
|
3339
|
+
const cart = await omni.createCart();
|
|
3340
|
+
cartId = cart.id;
|
|
3341
|
+
setServerCartId(cartId);
|
|
3342
|
+
|
|
3343
|
+
// Migrate local cart items to server cart
|
|
3344
|
+
const localCart = omni.getLocalCart();
|
|
3345
|
+
for (const item of localCart.items) {
|
|
3346
|
+
await omni.addToCart(cartId, {
|
|
3347
|
+
productId: item.productId,
|
|
3348
|
+
variantId: item.variantId,
|
|
3349
|
+
quantity: item.quantity,
|
|
3350
|
+
});
|
|
3351
|
+
}
|
|
3352
|
+
omni.clearLocalCart(); // Clear local cart after migration
|
|
3353
|
+
}
|
|
3354
|
+
|
|
3355
|
+
// Create checkout from server cart
|
|
3356
|
+
const checkout = await omni.createCheckout({ cartId });
|
|
3357
|
+
setCheckoutId(checkout.id);
|
|
3358
|
+
|
|
3359
|
+
// Pre-fill from customer profile if available
|
|
3360
|
+
try {
|
|
3361
|
+
const profile = await omni.getMyOrders({ limit: 1 }); // Just to check auth works
|
|
3362
|
+
} catch (e) {
|
|
3363
|
+
console.log('Could not fetch profile');
|
|
3364
|
+
}
|
|
3365
|
+
}
|
|
3366
|
+
setLoading(false);
|
|
3367
|
+
}
|
|
3368
|
+
|
|
3369
|
+
initCheckout();
|
|
3370
|
+
}, []);
|
|
3371
|
+
|
|
3372
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
3373
|
+
e.preventDefault();
|
|
3374
|
+
setSubmitting(true);
|
|
3375
|
+
|
|
3376
|
+
try {
|
|
3377
|
+
if (customerLoggedIn && checkoutId) {
|
|
3378
|
+
// ===== LOGGED-IN CUSTOMER: Server Checkout =====
|
|
3379
|
+
|
|
3380
|
+
// 1. Set customer info (REQUIRED - even for logged-in customers!)
|
|
3381
|
+
await omni.setCheckoutCustomer(checkoutId, {
|
|
3382
|
+
email: email, // Get from form or customer profile
|
|
3383
|
+
firstName: shippingAddress.firstName,
|
|
3384
|
+
lastName: shippingAddress.lastName,
|
|
3385
|
+
});
|
|
3386
|
+
|
|
3387
|
+
// 2. Set shipping address
|
|
3388
|
+
await omni.setShippingAddress(checkoutId, shippingAddress);
|
|
3389
|
+
|
|
3390
|
+
// 3. Get and select shipping rate
|
|
3391
|
+
const rates = await omni.getShippingRates(checkoutId);
|
|
3392
|
+
if (rates.length > 0) {
|
|
3393
|
+
await omni.selectShippingMethod(checkoutId, selectedRate || rates[0].id);
|
|
3394
|
+
}
|
|
3395
|
+
|
|
3396
|
+
// 4. Complete checkout - ORDER IS LINKED TO CUSTOMER!
|
|
3397
|
+
const { orderId } = await omni.completeCheckout(checkoutId);
|
|
3398
|
+
|
|
3399
|
+
// Clear cart ID
|
|
3400
|
+
localStorage.removeItem('cartId');
|
|
3401
|
+
|
|
3402
|
+
// Redirect to success page
|
|
3403
|
+
window.location.href = `/order-success?orderId=${orderId}`;
|
|
3404
|
+
|
|
3405
|
+
} else {
|
|
3406
|
+
// ===== GUEST: Local Cart + submitGuestOrder =====
|
|
3407
|
+
|
|
3408
|
+
// Set customer and shipping info on local cart
|
|
3409
|
+
omni.setLocalCartCustomer({ email });
|
|
3410
|
+
omni.setLocalCartShippingAddress(shippingAddress);
|
|
3411
|
+
|
|
3412
|
+
// Submit guest order (single API call)
|
|
3413
|
+
const order = await omni.submitGuestOrder();
|
|
3414
|
+
|
|
3415
|
+
// Redirect to success page
|
|
3416
|
+
window.location.href = `/order-success?orderId=${order.orderId}`;
|
|
3417
|
+
}
|
|
3418
|
+
} catch (error) {
|
|
3419
|
+
console.error('Checkout failed:', error);
|
|
3420
|
+
alert('Checkout failed. Please try again.');
|
|
3421
|
+
} finally {
|
|
3422
|
+
setSubmitting(false);
|
|
3423
|
+
}
|
|
3424
|
+
};
|
|
3425
|
+
|
|
3426
|
+
if (loading) return <div>Loading checkout...</div>;
|
|
3427
|
+
|
|
3428
|
+
return (
|
|
3429
|
+
<form onSubmit={handleSubmit}>
|
|
3430
|
+
{/* Show email field only for guests */}
|
|
3431
|
+
{!customerLoggedIn && (
|
|
3432
|
+
<input
|
|
3433
|
+
type="email"
|
|
3434
|
+
value={email}
|
|
3435
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
3436
|
+
placeholder="Email"
|
|
3437
|
+
required
|
|
3438
|
+
/>
|
|
3439
|
+
)}
|
|
3440
|
+
|
|
3441
|
+
{/* Shipping address fields */}
|
|
3442
|
+
<input value={shippingAddress.firstName} onChange={(e) => setShippingAddress({...shippingAddress, firstName: e.target.value})} placeholder="First Name" required />
|
|
3443
|
+
<input value={shippingAddress.lastName} onChange={(e) => setShippingAddress({...shippingAddress, lastName: e.target.value})} placeholder="Last Name" required />
|
|
3444
|
+
<input value={shippingAddress.line1} onChange={(e) => setShippingAddress({...shippingAddress, line1: e.target.value})} placeholder="Address" required />
|
|
3445
|
+
<input value={shippingAddress.city} onChange={(e) => setShippingAddress({...shippingAddress, city: e.target.value})} placeholder="City" required />
|
|
3446
|
+
<input value={shippingAddress.postalCode} onChange={(e) => setShippingAddress({...shippingAddress, postalCode: e.target.value})} placeholder="Postal Code" required />
|
|
3447
|
+
|
|
3448
|
+
{/* Shipping rates (for logged-in customers) */}
|
|
3449
|
+
{customerLoggedIn && shippingRates.length > 0 && (
|
|
3450
|
+
<select value={selectedRate || ''} onChange={(e) => setSelectedRate(e.target.value)}>
|
|
3451
|
+
{shippingRates.map((rate) => (
|
|
3452
|
+
<option key={rate.id} value={rate.id}>
|
|
3453
|
+
{rate.name} - ${rate.price}
|
|
3454
|
+
</option>
|
|
3455
|
+
))}
|
|
3456
|
+
</select>
|
|
3457
|
+
)}
|
|
3458
|
+
|
|
3459
|
+
<button type="submit" disabled={submitting}>
|
|
3460
|
+
{submitting ? 'Processing...' : 'Place Order'}
|
|
3461
|
+
</button>
|
|
3462
|
+
|
|
3463
|
+
{customerLoggedIn && (
|
|
3464
|
+
<p className="text-sm text-green-600">
|
|
3465
|
+
✓ Logged in - Order will be saved to your account
|
|
3466
|
+
</p>
|
|
3467
|
+
)}
|
|
3468
|
+
</form>
|
|
3469
|
+
);
|
|
3470
|
+
}
|
|
3471
|
+
```
|
|
3472
|
+
|
|
3473
|
+
> **Key Points:**
|
|
3474
|
+
>
|
|
3475
|
+
> - `isLoggedIn()` determines which flow to use
|
|
3476
|
+
> - Logged-in customers use `createCart()` → `createCheckout()` → `completeCheckout()`
|
|
3477
|
+
> - Guests use local cart + `submitGuestOrder()`
|
|
3478
|
+
> - Local cart items are migrated to server cart when customer logs in
|
|
3479
|
+
|
|
3480
|
+
---
|
|
3481
|
+
|
|
3482
|
+
### Guest Checkout (Single API Call)
|
|
3483
|
+
|
|
3484
|
+
This checkout is for **guest users only**. All cart data is in localStorage, and we submit it in one API call.
|
|
3485
|
+
|
|
3486
|
+
> **⚠️ WARNING:** Do NOT use this for logged-in customers! Use the Universal Checkout pattern above instead.
|
|
3487
|
+
|
|
3488
|
+
```typescript
|
|
3489
|
+
'use client';
|
|
3490
|
+
import { useState, useEffect } from 'react';
|
|
3491
|
+
import { omni } from '@/lib/brainerce';
|
|
3492
|
+
import type { LocalCart, GuestOrderResponse } from 'brainerce';
|
|
3493
|
+
|
|
3494
|
+
type Step = 'info' | 'review' | 'complete';
|
|
3495
|
+
|
|
3496
|
+
export default function CheckoutPage() {
|
|
3497
|
+
// ⚠️ Do NOT use useState(omni.getLocalCart()) — causes hydration mismatch!
|
|
3498
|
+
const [cart, setCart] = useState<LocalCart>({ items: [], updatedAt: '' });
|
|
3499
|
+
const [step, setStep] = useState<Step>('info');
|
|
3500
|
+
const [order, setOrder] = useState<GuestOrderResponse | null>(null);
|
|
3501
|
+
const [submitting, setSubmitting] = useState(false);
|
|
3502
|
+
const [error, setError] = useState('');
|
|
3503
|
+
|
|
3504
|
+
// Form state
|
|
3505
|
+
const [email, setEmail] = useState('');
|
|
3506
|
+
const [firstName, setFirstName] = useState('');
|
|
3507
|
+
const [lastName, setLastName] = useState('');
|
|
3508
|
+
const [address, setAddress] = useState('');
|
|
3509
|
+
const [city, setCity] = useState('');
|
|
3510
|
+
const [postalCode, setPostalCode] = useState('');
|
|
3511
|
+
const [country, setCountry] = useState('US');
|
|
3512
|
+
|
|
3513
|
+
// Load cart from localStorage after hydration
|
|
3514
|
+
useEffect(() => {
|
|
3515
|
+
const localCart = omni.getLocalCart();
|
|
3516
|
+
setCart(localCart);
|
|
3517
|
+
if (localCart.customer?.email) setEmail(localCart.customer.email);
|
|
3518
|
+
if (localCart.customer?.firstName) setFirstName(localCart.customer.firstName);
|
|
3519
|
+
if (localCart.customer?.lastName) setLastName(localCart.customer.lastName);
|
|
3520
|
+
if (localCart.shippingAddress?.line1) setAddress(localCart.shippingAddress.line1);
|
|
3521
|
+
if (localCart.shippingAddress?.city) setCity(localCart.shippingAddress.city);
|
|
3522
|
+
if (localCart.shippingAddress?.postalCode) setPostalCode(localCart.shippingAddress.postalCode);
|
|
3523
|
+
if (localCart.shippingAddress?.country) setCountry(localCart.shippingAddress.country);
|
|
3524
|
+
}, []);
|
|
3525
|
+
|
|
3526
|
+
// Calculate subtotal
|
|
3527
|
+
const subtotal = cart.items.reduce((sum, item) => {
|
|
3528
|
+
return sum + (parseFloat(item.price || '0') * item.quantity);
|
|
3529
|
+
}, 0);
|
|
3530
|
+
|
|
3531
|
+
// Redirect if cart is empty
|
|
3532
|
+
useEffect(() => {
|
|
3533
|
+
if (cart.items.length === 0 && step !== 'complete') {
|
|
3534
|
+
window.location.href = '/cart';
|
|
3535
|
+
}
|
|
3536
|
+
}, [cart.items.length, step]);
|
|
3537
|
+
|
|
3538
|
+
const handleInfoSubmit = (e: React.FormEvent) => {
|
|
3539
|
+
e.preventDefault();
|
|
3540
|
+
|
|
3541
|
+
// Save to local cart
|
|
3542
|
+
omni.setLocalCartCustomer({ email, firstName, lastName });
|
|
3543
|
+
omni.setLocalCartShippingAddress({
|
|
3544
|
+
firstName,
|
|
3545
|
+
lastName,
|
|
3546
|
+
line1: address,
|
|
3547
|
+
city,
|
|
3548
|
+
postalCode,
|
|
3549
|
+
country,
|
|
3550
|
+
});
|
|
3551
|
+
|
|
3552
|
+
setStep('review');
|
|
3553
|
+
};
|
|
3554
|
+
|
|
3555
|
+
const handlePlaceOrder = async () => {
|
|
3556
|
+
setSubmitting(true);
|
|
3557
|
+
setError('');
|
|
3558
|
+
|
|
3559
|
+
try {
|
|
3560
|
+
// Single API call to create order!
|
|
3561
|
+
const result = await omni.submitGuestOrder();
|
|
3562
|
+
setOrder(result);
|
|
3563
|
+
setStep('complete');
|
|
3564
|
+
} catch (err) {
|
|
3565
|
+
setError(err instanceof Error ? err.message : 'Failed to place order');
|
|
3566
|
+
} finally {
|
|
3567
|
+
setSubmitting(false);
|
|
3568
|
+
}
|
|
3569
|
+
};
|
|
3570
|
+
|
|
3571
|
+
if (step === 'complete' && order) {
|
|
3572
|
+
return (
|
|
3573
|
+
<div className="text-center py-12">
|
|
3574
|
+
<h1 className="text-3xl font-bold text-green-600">Order Complete!</h1>
|
|
3575
|
+
<p className="mt-4">Order Number: <strong>{order.orderNumber}</strong></p>
|
|
3576
|
+
<p className="mt-2">Total: <strong>${order.total.toFixed(2)}</strong></p>
|
|
3577
|
+
<p className="mt-4 text-gray-600">A confirmation email will be sent to {email}</p>
|
|
3578
|
+
<a href="/" className="mt-6 inline-block text-blue-600">Continue Shopping</a>
|
|
3579
|
+
</div>
|
|
3580
|
+
);
|
|
3581
|
+
}
|
|
3582
|
+
|
|
3583
|
+
return (
|
|
3584
|
+
<div className="max-w-2xl mx-auto">
|
|
3585
|
+
<h1 className="text-2xl font-bold mb-6">Checkout</h1>
|
|
3586
|
+
|
|
3587
|
+
{error && (
|
|
3588
|
+
<div className="bg-red-100 text-red-600 p-3 rounded mb-4">{error}</div>
|
|
3589
|
+
)}
|
|
3590
|
+
|
|
3591
|
+
{step === 'info' && (
|
|
3592
|
+
<form onSubmit={handleInfoSubmit} className="space-y-4">
|
|
3593
|
+
<h2 className="text-lg font-bold">Contact Information</h2>
|
|
3594
|
+
<input
|
|
3595
|
+
type="email"
|
|
3596
|
+
placeholder="Email"
|
|
3597
|
+
value={email}
|
|
3598
|
+
onChange={e => setEmail(e.target.value)}
|
|
3599
|
+
required
|
|
3600
|
+
className="w-full border p-2 rounded"
|
|
3601
|
+
/>
|
|
3602
|
+
|
|
3603
|
+
<h2 className="text-lg font-bold mt-6">Shipping Address</h2>
|
|
3604
|
+
<div className="grid grid-cols-2 gap-4">
|
|
3605
|
+
<input placeholder="First Name" value={firstName} onChange={e => setFirstName(e.target.value)} required className="border p-2 rounded" />
|
|
3606
|
+
<input placeholder="Last Name" value={lastName} onChange={e => setLastName(e.target.value)} required className="border p-2 rounded" />
|
|
3607
|
+
</div>
|
|
3608
|
+
<input placeholder="Address" value={address} onChange={e => setAddress(e.target.value)} required className="w-full border p-2 rounded" />
|
|
3609
|
+
<div className="grid grid-cols-2 gap-4">
|
|
3610
|
+
<input placeholder="City" value={city} onChange={e => setCity(e.target.value)} required className="border p-2 rounded" />
|
|
3611
|
+
<input placeholder="Postal Code" value={postalCode} onChange={e => setPostalCode(e.target.value)} required className="border p-2 rounded" />
|
|
3612
|
+
</div>
|
|
3613
|
+
<select value={country} onChange={e => setCountry(e.target.value)} className="w-full border p-2 rounded">
|
|
3614
|
+
<option value="US">United States</option>
|
|
3615
|
+
<option value="IL">Israel</option>
|
|
3616
|
+
<option value="GB">United Kingdom</option>
|
|
3617
|
+
</select>
|
|
3618
|
+
|
|
3619
|
+
<button type="submit" className="w-full bg-black text-white py-3 rounded">
|
|
3620
|
+
Review Order
|
|
3621
|
+
</button>
|
|
3622
|
+
</form>
|
|
3623
|
+
)}
|
|
3624
|
+
|
|
3625
|
+
{step === 'review' && (
|
|
3626
|
+
<div className="space-y-6">
|
|
3627
|
+
{/* Order Summary */}
|
|
3628
|
+
<div className="border p-4 rounded">
|
|
3629
|
+
<h3 className="font-bold mb-4">Order Summary</h3>
|
|
3630
|
+
{cart.items.map((item) => (
|
|
3631
|
+
<div key={`${item.productId}-${item.variantId || ''}`} className="flex justify-between py-2">
|
|
3632
|
+
<span>{item.name} x {item.quantity}</span>
|
|
3633
|
+
<span>${(parseFloat(item.price || '0') * item.quantity).toFixed(2)}</span>
|
|
3634
|
+
</div>
|
|
3635
|
+
))}
|
|
3636
|
+
<hr className="my-2" />
|
|
3637
|
+
<div className="flex justify-between font-bold">
|
|
3638
|
+
<span>Total</span>
|
|
3639
|
+
<span>${subtotal.toFixed(2)}</span>
|
|
3640
|
+
</div>
|
|
3641
|
+
</div>
|
|
3642
|
+
|
|
3643
|
+
{/* Shipping Info */}
|
|
3644
|
+
<div className="border p-4 rounded">
|
|
3645
|
+
<h3 className="font-bold mb-2">Shipping To</h3>
|
|
3646
|
+
<p>{firstName} {lastName}</p>
|
|
3647
|
+
<p>{address}</p>
|
|
3648
|
+
<p>{city}, {postalCode}, {country}</p>
|
|
3649
|
+
<button onClick={() => setStep('info')} className="text-blue-600 text-sm mt-2">Edit</button>
|
|
3650
|
+
</div>
|
|
3651
|
+
|
|
3652
|
+
<button
|
|
3653
|
+
onClick={handlePlaceOrder}
|
|
3654
|
+
disabled={submitting}
|
|
3655
|
+
className="w-full bg-green-600 text-white py-3 rounded text-lg"
|
|
3656
|
+
>
|
|
3657
|
+
{submitting ? 'Processing...' : 'Place Order'}
|
|
3658
|
+
</button>
|
|
3659
|
+
</div>
|
|
3660
|
+
)}
|
|
3661
|
+
</div>
|
|
3662
|
+
);
|
|
3663
|
+
}
|
|
3664
|
+
```
|
|
3665
|
+
|
|
3666
|
+
### Multi-Step Checkout (Server Cart - For Logged-In Customers Only)
|
|
3667
|
+
|
|
3668
|
+
> **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.
|
|
3669
|
+
|
|
3670
|
+
For logged-in users with server-side cart - orders will be linked to their account:
|
|
3671
|
+
|
|
3672
|
+
```typescript
|
|
3673
|
+
'use client';
|
|
3674
|
+
import { useEffect, useState } from 'react';
|
|
3675
|
+
import { omni, getServerCartId } from '@/lib/brainerce';
|
|
3676
|
+
import type { Checkout, ShippingRate } from 'brainerce';
|
|
3677
|
+
|
|
3678
|
+
type Step = 'customer' | 'shipping' | 'payment' | 'complete';
|
|
3679
|
+
|
|
3680
|
+
export default function CheckoutPage() {
|
|
3681
|
+
const [checkout, setCheckout] = useState<Checkout | null>(null);
|
|
3682
|
+
const [step, setStep] = useState<Step>('customer');
|
|
3683
|
+
const [shippingRates, setShippingRates] = useState<ShippingRate[]>([]);
|
|
3684
|
+
const [loading, setLoading] = useState(true);
|
|
3685
|
+
const [submitting, setSubmitting] = useState(false);
|
|
3686
|
+
|
|
3687
|
+
// Form state
|
|
3688
|
+
const [email, setEmail] = useState('');
|
|
3689
|
+
const [firstName, setFirstName] = useState('');
|
|
3690
|
+
const [lastName, setLastName] = useState('');
|
|
3691
|
+
const [address, setAddress] = useState('');
|
|
3692
|
+
const [city, setCity] = useState('');
|
|
3693
|
+
const [postalCode, setPostalCode] = useState('');
|
|
3694
|
+
const [country, setCountry] = useState('US');
|
|
3695
|
+
|
|
3696
|
+
useEffect(() => {
|
|
3697
|
+
async function initCheckout() {
|
|
3698
|
+
const cartId = getServerCartId();
|
|
3699
|
+
if (!cartId) {
|
|
3700
|
+
window.location.href = '/cart';
|
|
3701
|
+
return;
|
|
3702
|
+
}
|
|
3703
|
+
try {
|
|
3704
|
+
const c = await omni.createCheckout({ cartId });
|
|
3705
|
+
setCheckout(c);
|
|
3706
|
+
} finally {
|
|
3707
|
+
setLoading(false);
|
|
3708
|
+
}
|
|
3709
|
+
}
|
|
3710
|
+
initCheckout();
|
|
3711
|
+
}, []);
|
|
3712
|
+
|
|
3713
|
+
const handleCustomerSubmit = async (e: React.FormEvent) => {
|
|
3714
|
+
e.preventDefault();
|
|
3715
|
+
if (!checkout) return;
|
|
3716
|
+
setSubmitting(true);
|
|
3717
|
+
try {
|
|
3718
|
+
await omni.setCheckoutCustomer(checkout.id, { email, firstName, lastName });
|
|
3719
|
+
setStep('shipping');
|
|
3720
|
+
} finally {
|
|
3721
|
+
setSubmitting(false);
|
|
3722
|
+
}
|
|
3723
|
+
};
|
|
3724
|
+
|
|
3725
|
+
const handleShippingSubmit = async (e: React.FormEvent) => {
|
|
3726
|
+
e.preventDefault();
|
|
3727
|
+
if (!checkout) return;
|
|
3728
|
+
setSubmitting(true);
|
|
3729
|
+
try {
|
|
3730
|
+
const { rates } = await omni.setShippingAddress(checkout.id, {
|
|
3731
|
+
firstName, lastName,
|
|
3732
|
+
line1: address,
|
|
3733
|
+
city, postalCode, country,
|
|
3734
|
+
});
|
|
3735
|
+
setShippingRates(rates);
|
|
3736
|
+
if (rates.length > 0) {
|
|
3737
|
+
await omni.selectShippingMethod(checkout.id, rates[0].id);
|
|
3738
|
+
}
|
|
3739
|
+
setStep('payment');
|
|
3740
|
+
} finally {
|
|
3741
|
+
setSubmitting(false);
|
|
3742
|
+
}
|
|
3743
|
+
};
|
|
3744
|
+
|
|
3745
|
+
const handleCompleteOrder = async () => {
|
|
3746
|
+
if (!checkout) return;
|
|
3747
|
+
setSubmitting(true);
|
|
3748
|
+
try {
|
|
3749
|
+
const { orderId } = await omni.completeCheckout(checkout.id);
|
|
3750
|
+
setStep('complete');
|
|
3751
|
+
} catch (err) {
|
|
3752
|
+
alert('Failed to complete order');
|
|
3753
|
+
} finally {
|
|
3754
|
+
setSubmitting(false);
|
|
3755
|
+
}
|
|
3756
|
+
};
|
|
3757
|
+
|
|
3758
|
+
if (loading) return <div>Loading checkout...</div>;
|
|
3759
|
+
if (!checkout) return <div>Failed to create checkout</div>;
|
|
3760
|
+
|
|
3761
|
+
if (step === 'complete') {
|
|
3762
|
+
return (
|
|
3763
|
+
<div className="text-center py-12">
|
|
3764
|
+
<h1 className="text-3xl font-bold text-green-600">Order Complete!</h1>
|
|
3765
|
+
<p className="mt-4">Thank you for your purchase.</p>
|
|
3766
|
+
<a href="/" className="mt-6 inline-block text-blue-600">Continue Shopping</a>
|
|
3767
|
+
</div>
|
|
3768
|
+
);
|
|
3769
|
+
}
|
|
3770
|
+
|
|
3771
|
+
return (
|
|
3772
|
+
<div className="max-w-2xl mx-auto">
|
|
3773
|
+
<h1 className="text-2xl font-bold mb-6">Checkout</h1>
|
|
3774
|
+
|
|
3775
|
+
{step === 'customer' && (
|
|
3776
|
+
<form onSubmit={handleCustomerSubmit} className="space-y-4">
|
|
3777
|
+
<input type="email" placeholder="Email" value={email} onChange={e => setEmail(e.target.value)} required className="w-full border p-2 rounded" />
|
|
3778
|
+
<div className="grid grid-cols-2 gap-4">
|
|
3779
|
+
<input placeholder="First Name" value={firstName} onChange={e => setFirstName(e.target.value)} required className="border p-2 rounded" />
|
|
3780
|
+
<input placeholder="Last Name" value={lastName} onChange={e => setLastName(e.target.value)} required className="border p-2 rounded" />
|
|
3781
|
+
</div>
|
|
3782
|
+
<button type="submit" disabled={submitting} className="w-full bg-black text-white py-3 rounded">
|
|
3783
|
+
{submitting ? 'Saving...' : 'Continue to Shipping'}
|
|
3784
|
+
</button>
|
|
3785
|
+
</form>
|
|
3786
|
+
)}
|
|
3787
|
+
|
|
3788
|
+
{step === 'shipping' && (
|
|
3789
|
+
<form onSubmit={handleShippingSubmit} className="space-y-4">
|
|
3790
|
+
<input placeholder="Address" value={address} onChange={e => setAddress(e.target.value)} required className="w-full border p-2 rounded" />
|
|
3791
|
+
<div className="grid grid-cols-2 gap-4">
|
|
3792
|
+
<input placeholder="City" value={city} onChange={e => setCity(e.target.value)} required className="border p-2 rounded" />
|
|
3793
|
+
<input placeholder="Postal Code" value={postalCode} onChange={e => setPostalCode(e.target.value)} required className="border p-2 rounded" />
|
|
3794
|
+
</div>
|
|
3795
|
+
<select value={country} onChange={e => setCountry(e.target.value)} className="w-full border p-2 rounded">
|
|
3796
|
+
<option value="US">United States</option>
|
|
3797
|
+
<option value="IL">Israel</option>
|
|
3798
|
+
<option value="GB">United Kingdom</option>
|
|
3799
|
+
</select>
|
|
3800
|
+
<button type="submit" disabled={submitting} className="w-full bg-black text-white py-3 rounded">
|
|
3801
|
+
{submitting ? 'Calculating Shipping...' : 'Continue to Payment'}
|
|
3802
|
+
</button>
|
|
3803
|
+
</form>
|
|
3804
|
+
)}
|
|
3805
|
+
|
|
3806
|
+
{step === 'payment' && (
|
|
3807
|
+
<div className="space-y-6">
|
|
3808
|
+
<div className="border p-4 rounded">
|
|
3809
|
+
<h3 className="font-bold mb-2">Order Summary</h3>
|
|
3810
|
+
<p>Subtotal: ${checkout.subtotal}</p>
|
|
3811
|
+
<p>Shipping: ${checkout.shippingAmount}</p>
|
|
3812
|
+
<p className="text-xl font-bold mt-2">Total: ${checkout.total}</p>
|
|
3813
|
+
</div>
|
|
3814
|
+
<button onClick={handleCompleteOrder} disabled={submitting} className="w-full bg-green-600 text-white py-3 rounded text-lg">
|
|
3815
|
+
{submitting ? 'Processing...' : 'Complete Order'}
|
|
3816
|
+
</button>
|
|
3817
|
+
</div>
|
|
3818
|
+
)}
|
|
3819
|
+
</div>
|
|
3820
|
+
);
|
|
3821
|
+
}
|
|
3822
|
+
```
|
|
3823
|
+
|
|
3824
|
+
### Login Page
|
|
3825
|
+
|
|
3826
|
+
```typescript
|
|
3827
|
+
'use client';
|
|
3828
|
+
import { useState, useEffect } from 'react';
|
|
3829
|
+
import { omni, setCustomerToken } from '@/lib/brainerce';
|
|
3830
|
+
|
|
3831
|
+
export default function LoginPage() {
|
|
3832
|
+
const [email, setEmail] = useState('');
|
|
3833
|
+
const [password, setPassword] = useState('');
|
|
3834
|
+
const [error, setError] = useState('');
|
|
3835
|
+
const [loading, setLoading] = useState(false);
|
|
3836
|
+
|
|
3837
|
+
// Save the page user came from (for redirect after login)
|
|
3838
|
+
useEffect(() => {
|
|
3839
|
+
const referrer = document.referrer;
|
|
3840
|
+
if (referrer && !referrer.includes('/login') && !referrer.includes('/register')) {
|
|
3841
|
+
const url = new URL(referrer);
|
|
3842
|
+
localStorage.setItem('returnUrl', url.pathname);
|
|
3843
|
+
}
|
|
3844
|
+
}, []);
|
|
3845
|
+
|
|
3846
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
3847
|
+
e.preventDefault();
|
|
3848
|
+
setLoading(true);
|
|
3849
|
+
setError('');
|
|
3850
|
+
try {
|
|
3851
|
+
const auth = await omni.loginCustomer(email, password);
|
|
3852
|
+
setCustomerToken(auth.token);
|
|
3853
|
+
|
|
3854
|
+
// Redirect back to previous page or home (like Amazon/Shopify do)
|
|
3855
|
+
const returnUrl = localStorage.getItem('returnUrl') || '/';
|
|
3856
|
+
localStorage.removeItem('returnUrl');
|
|
3857
|
+
window.location.href = returnUrl;
|
|
3858
|
+
} catch (err) {
|
|
3859
|
+
setError('Invalid email or password');
|
|
3860
|
+
} finally {
|
|
3861
|
+
setLoading(false);
|
|
3862
|
+
}
|
|
3863
|
+
};
|
|
3864
|
+
|
|
3865
|
+
return (
|
|
3866
|
+
<div className="max-w-md mx-auto mt-12">
|
|
3867
|
+
<h1 className="text-2xl font-bold mb-6">Login</h1>
|
|
3868
|
+
{error && <div className="bg-red-100 text-red-600 p-3 rounded mb-4">{error}</div>}
|
|
3869
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
3870
|
+
<input type="email" placeholder="Email" value={email} onChange={e => setEmail(e.target.value)} required className="w-full border p-2 rounded" />
|
|
3871
|
+
<input type="password" placeholder="Password" value={password} onChange={e => setPassword(e.target.value)} required className="w-full border p-2 rounded" />
|
|
3872
|
+
<button type="submit" disabled={loading} className="w-full bg-black text-white py-3 rounded">
|
|
3873
|
+
{loading ? 'Logging in...' : 'Login'}
|
|
3874
|
+
</button>
|
|
3875
|
+
</form>
|
|
3876
|
+
<p className="mt-4 text-center">
|
|
3877
|
+
Don't have an account? <a href="/register" className="text-blue-600">Register</a>
|
|
3878
|
+
</p>
|
|
3879
|
+
</div>
|
|
3880
|
+
);
|
|
3881
|
+
}
|
|
3882
|
+
```
|
|
3883
|
+
|
|
3884
|
+
### Register Page
|
|
3885
|
+
|
|
3886
|
+
```typescript
|
|
3887
|
+
'use client';
|
|
3888
|
+
import { useState, useEffect } from 'react';
|
|
3889
|
+
import { omni, setCustomerToken } from '@/lib/brainerce';
|
|
3890
|
+
|
|
3891
|
+
export default function RegisterPage() {
|
|
3892
|
+
const [email, setEmail] = useState('');
|
|
3893
|
+
const [password, setPassword] = useState('');
|
|
3894
|
+
const [firstName, setFirstName] = useState('');
|
|
3895
|
+
const [lastName, setLastName] = useState('');
|
|
3896
|
+
const [error, setError] = useState('');
|
|
3897
|
+
const [loading, setLoading] = useState(false);
|
|
3898
|
+
|
|
3899
|
+
// Save the page user came from (for redirect after registration)
|
|
3900
|
+
useEffect(() => {
|
|
3901
|
+
const referrer = document.referrer;
|
|
3902
|
+
if (referrer && !referrer.includes('/login') && !referrer.includes('/register')) {
|
|
3903
|
+
const url = new URL(referrer);
|
|
3904
|
+
localStorage.setItem('returnUrl', url.pathname);
|
|
3905
|
+
}
|
|
3906
|
+
}, []);
|
|
3907
|
+
|
|
3908
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
3909
|
+
e.preventDefault();
|
|
3910
|
+
setLoading(true);
|
|
3911
|
+
setError('');
|
|
3912
|
+
try {
|
|
3913
|
+
const auth = await omni.registerCustomer({ email, password, firstName, lastName });
|
|
3914
|
+
|
|
3915
|
+
// Check if email verification is required
|
|
3916
|
+
if (auth.requiresVerification) {
|
|
3917
|
+
// Save token for verification step
|
|
3918
|
+
localStorage.setItem('verificationToken', auth.token);
|
|
3919
|
+
window.location.href = '/verify-email';
|
|
3920
|
+
} else {
|
|
3921
|
+
// No verification needed - redirect back to store (like Amazon/Shopify)
|
|
3922
|
+
setCustomerToken(auth.token);
|
|
3923
|
+
const returnUrl = localStorage.getItem('returnUrl') || '/';
|
|
3924
|
+
localStorage.removeItem('returnUrl');
|
|
3925
|
+
window.location.href = returnUrl;
|
|
3926
|
+
}
|
|
3927
|
+
} catch (err) {
|
|
3928
|
+
setError('Registration failed. Email may already be in use.');
|
|
3929
|
+
} finally {
|
|
3930
|
+
setLoading(false);
|
|
3931
|
+
}
|
|
3932
|
+
};
|
|
3933
|
+
|
|
3934
|
+
return (
|
|
3935
|
+
<div className="max-w-md mx-auto mt-12">
|
|
3936
|
+
<h1 className="text-2xl font-bold mb-6">Create Account</h1>
|
|
3937
|
+
{error && <div className="bg-red-100 text-red-600 p-3 rounded mb-4">{error}</div>}
|
|
3938
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
3939
|
+
<div className="grid grid-cols-2 gap-4">
|
|
3940
|
+
<input placeholder="First Name" value={firstName} onChange={e => setFirstName(e.target.value)} required className="border p-2 rounded" />
|
|
3941
|
+
<input placeholder="Last Name" value={lastName} onChange={e => setLastName(e.target.value)} required className="border p-2 rounded" />
|
|
3942
|
+
</div>
|
|
3943
|
+
<input type="email" placeholder="Email" value={email} onChange={e => setEmail(e.target.value)} required className="w-full border p-2 rounded" />
|
|
3944
|
+
<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" />
|
|
3945
|
+
<button type="submit" disabled={loading} className="w-full bg-black text-white py-3 rounded">
|
|
3946
|
+
{loading ? 'Creating Account...' : 'Create Account'}
|
|
3947
|
+
</button>
|
|
3948
|
+
</form>
|
|
3949
|
+
<p className="mt-4 text-center">
|
|
3950
|
+
Already have an account? <a href="/login" className="text-blue-600">Login</a>
|
|
3951
|
+
</p>
|
|
3952
|
+
</div>
|
|
3953
|
+
);
|
|
3954
|
+
}
|
|
3955
|
+
```
|
|
3956
|
+
|
|
3957
|
+
### Account Page
|
|
3958
|
+
|
|
3959
|
+
```typescript
|
|
3960
|
+
'use client';
|
|
3961
|
+
import { useEffect, useState } from 'react';
|
|
3962
|
+
import { omni, restoreCustomerToken, setCustomerToken, isLoggedIn } from '@/lib/brainerce';
|
|
3963
|
+
import type { CustomerProfile, Order } from 'brainerce';
|
|
3964
|
+
|
|
3965
|
+
export default function AccountPage() {
|
|
3966
|
+
const [profile, setProfile] = useState<CustomerProfile | null>(null);
|
|
3967
|
+
const [orders, setOrders] = useState<Order[]>([]);
|
|
3968
|
+
const [loading, setLoading] = useState(true);
|
|
3969
|
+
|
|
3970
|
+
useEffect(() => {
|
|
3971
|
+
restoreCustomerToken();
|
|
3972
|
+
if (!isLoggedIn()) {
|
|
3973
|
+
window.location.href = '/login';
|
|
3974
|
+
return;
|
|
3975
|
+
}
|
|
3976
|
+
|
|
3977
|
+
async function load() {
|
|
3978
|
+
try {
|
|
3979
|
+
const [p, o] = await Promise.all([
|
|
3980
|
+
omni.getMyProfile(),
|
|
3981
|
+
omni.getMyOrders({ limit: 10 }),
|
|
3982
|
+
]);
|
|
3983
|
+
setProfile(p);
|
|
3984
|
+
setOrders(o.data);
|
|
3985
|
+
} finally {
|
|
3986
|
+
setLoading(false);
|
|
3987
|
+
}
|
|
3988
|
+
}
|
|
3989
|
+
load();
|
|
3990
|
+
}, []);
|
|
3991
|
+
|
|
3992
|
+
const handleLogout = () => {
|
|
3993
|
+
setCustomerToken(null);
|
|
3994
|
+
window.location.href = '/';
|
|
3995
|
+
};
|
|
3996
|
+
|
|
3997
|
+
if (loading) return <div>Loading...</div>;
|
|
3998
|
+
if (!profile) return <div>Please log in</div>;
|
|
3999
|
+
|
|
4000
|
+
return (
|
|
4001
|
+
<div>
|
|
4002
|
+
<div className="flex justify-between items-center mb-8">
|
|
4003
|
+
<h1 className="text-2xl font-bold">My Account</h1>
|
|
4004
|
+
<button onClick={handleLogout} className="text-red-600">Logout</button>
|
|
4005
|
+
</div>
|
|
4006
|
+
|
|
4007
|
+
<div className="grid md:grid-cols-2 gap-8">
|
|
4008
|
+
<div className="border rounded p-6">
|
|
4009
|
+
<h2 className="text-xl font-bold mb-4">Profile</h2>
|
|
4010
|
+
<p><strong>Name:</strong> {profile.firstName} {profile.lastName}</p>
|
|
4011
|
+
<p><strong>Email:</strong> {profile.email}</p>
|
|
4012
|
+
</div>
|
|
4013
|
+
|
|
4014
|
+
<div className="border rounded p-6">
|
|
4015
|
+
<h2 className="text-xl font-bold mb-4">Recent Orders</h2>
|
|
4016
|
+
{orders.length === 0 ? (
|
|
4017
|
+
<p className="text-gray-500">No orders yet</p>
|
|
4018
|
+
) : (
|
|
4019
|
+
<div className="space-y-4">
|
|
4020
|
+
{orders.map((order) => (
|
|
4021
|
+
<div key={order.id} className="border-b pb-4">
|
|
4022
|
+
<span className="font-medium">#{order.id.slice(-8)}</span>
|
|
4023
|
+
<span className="ml-2 text-sm">{order.status}</span>
|
|
4024
|
+
<p className="font-bold">${order.totalAmount}</p>
|
|
4025
|
+
</div>
|
|
4026
|
+
))}
|
|
4027
|
+
</div>
|
|
4028
|
+
)}
|
|
4029
|
+
</div>
|
|
4030
|
+
</div>
|
|
4031
|
+
</div>
|
|
4032
|
+
);
|
|
4033
|
+
}
|
|
4034
|
+
```
|
|
4035
|
+
|
|
4036
|
+
### Header Component with Cart Count (Local Cart)
|
|
4037
|
+
|
|
4038
|
+
```typescript
|
|
4039
|
+
'use client';
|
|
4040
|
+
import { useState, useEffect } from 'react';
|
|
4041
|
+
import { omni, isLoggedIn } from '@/lib/brainerce';
|
|
4042
|
+
|
|
4043
|
+
export function Header() {
|
|
4044
|
+
const [cartCount, setCartCount] = useState(0);
|
|
4045
|
+
const [loggedIn, setLoggedIn] = useState(false);
|
|
4046
|
+
|
|
4047
|
+
useEffect(() => {
|
|
4048
|
+
setLoggedIn(isLoggedIn());
|
|
4049
|
+
// Get cart count from local storage (NO API call!)
|
|
4050
|
+
setCartCount(omni.getLocalCartItemCount());
|
|
4051
|
+
}, []);
|
|
4052
|
+
|
|
4053
|
+
return (
|
|
4054
|
+
<header className="flex justify-between items-center p-4 border-b">
|
|
4055
|
+
<a href="/" className="text-xl font-bold">Store Name</a>
|
|
4056
|
+
<nav className="flex gap-6 items-center">
|
|
4057
|
+
<a href="/products">Shop</a>
|
|
4058
|
+
<a href="/cart" className="relative">
|
|
4059
|
+
Cart
|
|
4060
|
+
{cartCount > 0 && (
|
|
4061
|
+
<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">
|
|
4062
|
+
{cartCount}
|
|
4063
|
+
</span>
|
|
4064
|
+
)}
|
|
4065
|
+
</a>
|
|
4066
|
+
{loggedIn ? (
|
|
4067
|
+
<a href="/account">Account</a>
|
|
4068
|
+
) : (
|
|
4069
|
+
<a href="/login">Login</a>
|
|
4070
|
+
)}
|
|
4071
|
+
</nav>
|
|
4072
|
+
</header>
|
|
4073
|
+
);
|
|
4074
|
+
}
|
|
4075
|
+
```
|
|
4076
|
+
|
|
4077
|
+
---
|
|
4078
|
+
|
|
4079
|
+
## Error Handling
|
|
4080
|
+
|
|
4081
|
+
```typescript
|
|
4082
|
+
import { BrainerceClient, BrainerceError } from 'brainerce';
|
|
4083
|
+
|
|
4084
|
+
try {
|
|
4085
|
+
const product = await omni.getProduct('invalid_id');
|
|
4086
|
+
} catch (error) {
|
|
4087
|
+
if (error instanceof BrainerceError) {
|
|
4088
|
+
console.error(`API Error: ${error.message}`);
|
|
4089
|
+
console.error(`Status Code: ${error.statusCode}`);
|
|
4090
|
+
console.error(`Details:`, error.details);
|
|
4091
|
+
}
|
|
4092
|
+
}
|
|
4093
|
+
```
|
|
4094
|
+
|
|
4095
|
+
---
|
|
4096
|
+
|
|
4097
|
+
## Webhooks
|
|
4098
|
+
|
|
4099
|
+
Receive real-time updates when products, orders, or inventory change.
|
|
4100
|
+
|
|
4101
|
+
### Setup Webhook Endpoint
|
|
4102
|
+
|
|
4103
|
+
```typescript
|
|
4104
|
+
// api/webhooks/brainerce/route.ts (Next.js App Router)
|
|
4105
|
+
import { verifyWebhook, createWebhookHandler } from 'brainerce';
|
|
4106
|
+
|
|
4107
|
+
const handler = createWebhookHandler({
|
|
4108
|
+
'product.updated': async (event) => {
|
|
4109
|
+
console.log('Product updated:', event.entityId);
|
|
4110
|
+
// Invalidate cache, update UI, etc.
|
|
4111
|
+
},
|
|
4112
|
+
'inventory.updated': async (event) => {
|
|
4113
|
+
console.log('Stock changed:', event.data);
|
|
4114
|
+
},
|
|
4115
|
+
'order.created': async (event) => {
|
|
4116
|
+
console.log('New order from:', event.platform);
|
|
4117
|
+
},
|
|
4118
|
+
});
|
|
4119
|
+
|
|
4120
|
+
export async function POST(req: Request) {
|
|
4121
|
+
const signature = req.headers.get('x-omni-signature');
|
|
4122
|
+
const body = await req.json();
|
|
4123
|
+
|
|
4124
|
+
// Verify signature
|
|
4125
|
+
if (!verifyWebhook(body, signature, process.env.OMNI_SYNC_WEBHOOK_SECRET!)) {
|
|
4126
|
+
return new Response('Invalid signature', { status: 401 });
|
|
4127
|
+
}
|
|
4128
|
+
|
|
4129
|
+
// Process event
|
|
4130
|
+
await handler(body);
|
|
4131
|
+
|
|
4132
|
+
return new Response('OK');
|
|
4133
|
+
}
|
|
4134
|
+
```
|
|
4135
|
+
|
|
4136
|
+
### Webhook Events
|
|
4137
|
+
|
|
4138
|
+
| Event | Description |
|
|
4139
|
+
| -------------------- | ------------------------------- |
|
|
4140
|
+
| `product.created` | New product created |
|
|
4141
|
+
| `product.updated` | Product details changed |
|
|
4142
|
+
| `product.deleted` | Product removed |
|
|
4143
|
+
| `inventory.updated` | Stock levels changed |
|
|
4144
|
+
| `order.created` | New order received |
|
|
4145
|
+
| `order.updated` | Order status changed |
|
|
4146
|
+
| `cart.abandoned` | Cart abandoned (no activity) |
|
|
4147
|
+
| `checkout.completed` | Checkout completed successfully |
|
|
4148
|
+
|
|
4149
|
+
---
|
|
4150
|
+
|
|
4151
|
+
## TypeScript Support
|
|
4152
|
+
|
|
4153
|
+
All types are exported for full TypeScript support:
|
|
4154
|
+
|
|
4155
|
+
```typescript
|
|
4156
|
+
import type {
|
|
4157
|
+
// Products
|
|
4158
|
+
Product,
|
|
4159
|
+
ProductImage,
|
|
4160
|
+
ProductVariant,
|
|
4161
|
+
InventoryInfo,
|
|
4162
|
+
ProductQueryParams,
|
|
4163
|
+
PaginatedResponse,
|
|
4164
|
+
|
|
4165
|
+
// Local Cart (Guest Users)
|
|
4166
|
+
LocalCart,
|
|
4167
|
+
LocalCartItem,
|
|
4168
|
+
CreateGuestOrderDto,
|
|
4169
|
+
GuestOrderResponse,
|
|
4170
|
+
|
|
4171
|
+
// Server Cart (Registered Users)
|
|
4172
|
+
Cart,
|
|
4173
|
+
CartItem,
|
|
4174
|
+
AddToCartDto,
|
|
4175
|
+
|
|
4176
|
+
// Checkout
|
|
4177
|
+
Checkout,
|
|
4178
|
+
CheckoutStatus,
|
|
4179
|
+
ShippingRate,
|
|
4180
|
+
SetShippingAddressDto,
|
|
4181
|
+
|
|
4182
|
+
// Customer
|
|
4183
|
+
Customer,
|
|
4184
|
+
CustomerProfile,
|
|
4185
|
+
CustomerAddress,
|
|
4186
|
+
CustomerAuthResponse,
|
|
4187
|
+
|
|
4188
|
+
// Orders
|
|
4189
|
+
Order,
|
|
4190
|
+
OrderStatus,
|
|
4191
|
+
OrderItem,
|
|
4192
|
+
|
|
4193
|
+
// Webhooks
|
|
4194
|
+
WebhookEvent,
|
|
4195
|
+
WebhookEventType,
|
|
4196
|
+
|
|
4197
|
+
// Errors
|
|
4198
|
+
BrainerceError,
|
|
4199
|
+
} from 'brainerce';
|
|
4200
|
+
```
|
|
4201
|
+
|
|
4202
|
+
---
|
|
4203
|
+
|
|
4204
|
+
## Environment Variables
|
|
4205
|
+
|
|
4206
|
+
```env
|
|
4207
|
+
# Required for vibe-coded sites
|
|
4208
|
+
NEXT_PUBLIC_OMNI_CONNECTION_ID=vc_your_connection_id
|
|
4209
|
+
|
|
4210
|
+
# Optional: Override API URL (default: https://api.brainerce.com)
|
|
4211
|
+
NEXT_PUBLIC_OMNI_API_URL=https://api.brainerce.com
|
|
4212
|
+
|
|
4213
|
+
# For webhooks (server-side only)
|
|
4214
|
+
OMNI_SYNC_WEBHOOK_SECRET=your_webhook_secret
|
|
4215
|
+
```
|
|
4216
|
+
|
|
4217
|
+
---
|
|
4218
|
+
|
|
4219
|
+
## Required Pages Checklist
|
|
4220
|
+
|
|
4221
|
+
When building a store, implement these pages:
|
|
4222
|
+
|
|
4223
|
+
- [ ] **Home** (`/`) - Product grid
|
|
4224
|
+
- [ ] **Products** (`/products`) - Product list with pagination
|
|
4225
|
+
- [ ] **Product Detail** (`/products/[slug]`) - Single product with Add to Cart (use `getProductBySlug(slug)`)
|
|
4226
|
+
- [ ] **Cart** (`/cart`) - Cart items, update quantity, remove
|
|
4227
|
+
- [ ] **Checkout** (`/checkout`) - Multi-step checkout flow
|
|
4228
|
+
- [ ] **⚠️ Payment** (`/checkout/payment`) - **REQUIRED!** Use `getPaymentProviders()` to show Stripe/PayPal forms
|
|
4229
|
+
- [ ] **Login** (`/login`) - Customer login + social login buttons (Google/Facebook/GitHub if available)
|
|
4230
|
+
- [ ] **Register** (`/register`) - Customer registration + social signup buttons
|
|
4231
|
+
- [ ] **Auth Callback** (`/auth/callback`) - Handle OAuth redirects from Google/Facebook/GitHub
|
|
4232
|
+
- [ ] **Verify Email** (`/verify-email`) - Email verification with 6-digit code (if store requires it)
|
|
4233
|
+
- [ ] **Account** (`/account`) - Profile and order history
|
|
4234
|
+
|
|
4235
|
+
### ⚠️ Payment Page is REQUIRED
|
|
4236
|
+
|
|
4237
|
+
Without a payment page, customers cannot complete orders! See [Payment Integration](#payment-integration-vibe-coded-sites) for implementation.
|
|
4238
|
+
|
|
4239
|
+
---
|
|
4240
|
+
|
|
4241
|
+
## Error Handling & Toast Notifications
|
|
4242
|
+
|
|
4243
|
+
For a polished user experience, use toast notifications to show success/error messages. We recommend [Sonner](https://sonner.emilkowal.ski/) - a lightweight toast library.
|
|
4244
|
+
|
|
4245
|
+
### Setup Toast Notifications
|
|
4246
|
+
|
|
4247
|
+
```bash
|
|
4248
|
+
npm install sonner
|
|
4249
|
+
```
|
|
4250
|
+
|
|
4251
|
+
Add the Toaster component to your app layout:
|
|
4252
|
+
|
|
4253
|
+
```tsx
|
|
4254
|
+
// app/layout.tsx or App.tsx
|
|
4255
|
+
import { Toaster } from 'sonner';
|
|
4256
|
+
|
|
4257
|
+
export default function RootLayout({ children }) {
|
|
4258
|
+
return (
|
|
4259
|
+
<html>
|
|
4260
|
+
<body>
|
|
4261
|
+
{children}
|
|
4262
|
+
<Toaster
|
|
4263
|
+
position="top-right"
|
|
4264
|
+
richColors
|
|
4265
|
+
closeButton
|
|
4266
|
+
toastOptions={{
|
|
4267
|
+
duration: 4000,
|
|
4268
|
+
}}
|
|
4269
|
+
/>
|
|
4270
|
+
</body>
|
|
4271
|
+
</html>
|
|
4272
|
+
);
|
|
4273
|
+
}
|
|
4274
|
+
```
|
|
4275
|
+
|
|
4276
|
+
### Handling SDK Errors
|
|
4277
|
+
|
|
4278
|
+
The SDK throws `BrainerceError` with helpful messages. Wrap SDK calls in try/catch:
|
|
4279
|
+
|
|
4280
|
+
```tsx
|
|
4281
|
+
import { toast } from 'sonner';
|
|
4282
|
+
import { BrainerceError } from 'brainerce';
|
|
4283
|
+
|
|
4284
|
+
// Add to cart with toast feedback
|
|
4285
|
+
const handleAddToCart = async (productId: string, quantity: number) => {
|
|
4286
|
+
try {
|
|
4287
|
+
omni.addToLocalCart({ productId, quantity });
|
|
4288
|
+
toast.success('Added to cart!');
|
|
4289
|
+
} catch (error) {
|
|
4290
|
+
if (error instanceof BrainerceError) {
|
|
4291
|
+
toast.error(error.message);
|
|
4292
|
+
} else {
|
|
4293
|
+
toast.error('Something went wrong');
|
|
4294
|
+
}
|
|
4295
|
+
}
|
|
4296
|
+
};
|
|
4297
|
+
|
|
4298
|
+
// Checkout with toast feedback
|
|
4299
|
+
const handleCheckout = async () => {
|
|
4300
|
+
try {
|
|
4301
|
+
const order = await omni.submitGuestOrder();
|
|
4302
|
+
toast.success(`Order placed! Order #${order.orderNumber}`);
|
|
4303
|
+
// Navigate to success page
|
|
4304
|
+
} catch (error) {
|
|
4305
|
+
if (error instanceof BrainerceError) {
|
|
4306
|
+
// Show specific error message from SDK
|
|
4307
|
+
toast.error(error.message);
|
|
4308
|
+
} else {
|
|
4309
|
+
toast.error('Failed to place order. Please try again.');
|
|
4310
|
+
}
|
|
4311
|
+
}
|
|
4312
|
+
};
|
|
4313
|
+
```
|
|
4314
|
+
|
|
4315
|
+
### Common Error Messages
|
|
4316
|
+
|
|
4317
|
+
| Error | When it occurs |
|
|
4318
|
+
| ------------------------------ | ---------------------------------- |
|
|
4319
|
+
| `Cart is empty` | Trying to checkout with empty cart |
|
|
4320
|
+
| `Customer email is required` | Missing email at checkout |
|
|
4321
|
+
| `Shipping address is required` | Missing shipping address |
|
|
4322
|
+
| `Product not found` | Invalid product ID |
|
|
4323
|
+
| `Insufficient inventory` | Not enough stock |
|
|
4324
|
+
| `Invalid quantity` | Quantity < 1 or > available |
|
|
4325
|
+
|
|
4326
|
+
### Custom Hook for SDK Operations (Optional)
|
|
4327
|
+
|
|
4328
|
+
Create a reusable hook for SDK operations with automatic toast handling:
|
|
4329
|
+
|
|
4330
|
+
```tsx
|
|
4331
|
+
// hooks/useOmniAction.ts
|
|
4332
|
+
import { useState } from 'react';
|
|
4333
|
+
import { toast } from 'sonner';
|
|
4334
|
+
import { BrainerceError } from 'brainerce';
|
|
4335
|
+
|
|
4336
|
+
export function useOmniAction<T>() {
|
|
4337
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
4338
|
+
|
|
4339
|
+
const execute = async (
|
|
4340
|
+
action: () => Promise<T>,
|
|
4341
|
+
options?: {
|
|
4342
|
+
successMessage?: string;
|
|
4343
|
+
errorMessage?: string;
|
|
4344
|
+
onSuccess?: (result: T) => void;
|
|
4345
|
+
}
|
|
4346
|
+
): Promise<T | null> => {
|
|
4347
|
+
setIsLoading(true);
|
|
4348
|
+
try {
|
|
4349
|
+
const result = await action();
|
|
4350
|
+
if (options?.successMessage) {
|
|
4351
|
+
toast.success(options.successMessage);
|
|
4352
|
+
}
|
|
4353
|
+
options?.onSuccess?.(result);
|
|
4354
|
+
return result;
|
|
4355
|
+
} catch (error) {
|
|
4356
|
+
const message =
|
|
4357
|
+
error instanceof BrainerceError
|
|
4358
|
+
? error.message
|
|
4359
|
+
: options?.errorMessage || 'Something went wrong';
|
|
4360
|
+
toast.error(message);
|
|
4361
|
+
return null;
|
|
4362
|
+
} finally {
|
|
4363
|
+
setIsLoading(false);
|
|
4364
|
+
}
|
|
4365
|
+
};
|
|
4366
|
+
|
|
4367
|
+
return { execute, isLoading };
|
|
4368
|
+
}
|
|
4369
|
+
|
|
4370
|
+
// Usage:
|
|
4371
|
+
const { execute, isLoading } = useOmniAction();
|
|
4372
|
+
|
|
4373
|
+
const handlePlaceOrder = () => {
|
|
4374
|
+
execute(() => omni.submitGuestOrder(), {
|
|
4375
|
+
successMessage: 'Order placed successfully!',
|
|
4376
|
+
onSuccess: (order) => navigate(`/order/${order.orderId}`),
|
|
4377
|
+
});
|
|
4378
|
+
};
|
|
4379
|
+
```
|
|
4380
|
+
|
|
4381
|
+
---
|
|
4382
|
+
|
|
4383
|
+
## Important Rules
|
|
4384
|
+
|
|
4385
|
+
### DO:
|
|
4386
|
+
|
|
4387
|
+
- Install `brainerce` and use it for ALL data
|
|
4388
|
+
- Import types from the SDK
|
|
4389
|
+
- Handle loading states and errors
|
|
4390
|
+
- **Use toast notifications (Sonner) for user feedback on actions**
|
|
4391
|
+
- Persist cart ID in localStorage
|
|
4392
|
+
- Persist customer token after login
|
|
4393
|
+
- **Use `isHtmlDescription(product)` helper and render HTML with `dangerouslySetInnerHTML` when it returns true**
|
|
4394
|
+
- **Wrap SDK calls in try/catch and show error toasts**
|
|
4395
|
+
|
|
4396
|
+
### DON'T:
|
|
4397
|
+
|
|
4398
|
+
- Create mock/hardcoded product data
|
|
4399
|
+
- Use localStorage for products
|
|
4400
|
+
- Skip implementing required pages
|
|
4401
|
+
- Write `const products = [...]` - use the API!
|
|
4402
|
+
- Use `@apply group` in CSS - Tailwind doesn't allow 'group' in @apply. Use `className="group"` on the element instead
|
|
4403
|
+
- **Render `product.description` as plain text without using `isHtmlDescription()` - HTML will show as raw tags like `<p>`, `<ul>`, `<li>`!**
|
|
4404
|
+
|
|
4405
|
+
---
|
|
4406
|
+
|
|
4407
|
+
## License
|
|
4408
|
+
|
|
4409
|
+
MIT
|