brainerce 1.63.0 → 2.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +665 -298
- package/dist/index.d.mts +530 -201
- package/dist/index.d.ts +530 -201
- package/dist/index.js +389 -244
- package/dist/index.mjs +389 -244
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,17 +5,17 @@ Official SDK for building e-commerce storefronts with **Brainerce Platform**.
|
|
|
5
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
6
|
|
|
7
7
|
> **AI Agents / Vibe Coders (Cursor, Lovable, Claude Code, VS Code):** Use the MCP server for AI-powered store building: `npx @brainerce/mcp-server`. It provides docs, code templates, and live store capabilities directly inside your IDE.
|
|
8
|
-
> Note: the MCP server runs inside your IDE
|
|
8
|
+
> Note: the MCP server runs inside your IDE. It is not available in chat-only tools like Google AI Studio or ChatGPT.
|
|
9
9
|
|
|
10
|
-
## Three SDK modes
|
|
10
|
+
## Three SDK modes: choose the right one
|
|
11
11
|
|
|
12
12
|
The exported class is **`BrainerceClient`**. Which mode you get is decided by which key you pass to its constructor:
|
|
13
13
|
|
|
14
|
-
| Mode | Config key | Use for | Where to run
|
|
15
|
-
| ----------------- | ------------------------ | ---------------------------------------- |
|
|
16
|
-
| **Sales channel** | `salesChannelId: 'vc_*'` | Building the customer-facing store | Browser / client-side
|
|
17
|
-
| **Storefront** | `storeId` | A public storefront on a published store | Browser / client-side
|
|
18
|
-
| **Admin** | `apiKey: 'brainerce_*'` | Managing products, team, settings | Server only
|
|
14
|
+
| Mode | Config key | Use for | Where to run |
|
|
15
|
+
| ----------------- | ------------------------ | ---------------------------------------- | ---------------------------------- |
|
|
16
|
+
| **Sales channel** | `salesChannelId: 'vc_*'` | Building the customer-facing store | Browser / client-side |
|
|
17
|
+
| **Storefront** | `storeId` | A public storefront on a published store | Browser / client-side |
|
|
18
|
+
| **Admin** | `apiKey: 'brainerce_*'` | Managing products, team, settings | Server only, never in browser code |
|
|
19
19
|
|
|
20
20
|
```ts
|
|
21
21
|
import { BrainerceClient } from 'brainerce';
|
|
@@ -23,15 +23,15 @@ import { BrainerceClient } from 'brainerce';
|
|
|
23
23
|
const client = new BrainerceClient({ salesChannelId: 'vc_abc123' });
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
-
**If you pass more than one, `apiKey` wins, then `salesChannelId`, then `storeId`.** An `apiKey` puts the client in admin mode no matter what else you passed, so never add one "just to also read a channel"
|
|
26
|
+
**If you pass more than one, `apiKey` wins, then `salesChannelId`, then `storeId`.** An `apiKey` puts the client in admin mode no matter what else you passed, so never add one "just to also read a channel"; it changes every route the client calls. Passing none throws `BrainerceClient: either salesChannelId, apiKey, or storeId is required`.
|
|
27
27
|
|
|
28
|
-
Ask the client which mode it is in with `isSalesChannelMode()`, `isStorefrontMode()` or `isAdminMode()
|
|
28
|
+
Ask the client which mode it is in with `isSalesChannelMode()`, `isStorefrontMode()` or `isAdminMode()`. Exactly one returns `true`. (`isVibeCodedMode()` is a deprecated alias of `isSalesChannelMode()`.)
|
|
29
29
|
|
|
30
|
-
Not every method works in every mode. A handful
|
|
30
|
+
Not every method works in every mode. A handful (`getPaymentStatus()`, `confirmSdkPayment()`, `waitForOrder()`) are **sales-channel mode only** and throw `BrainerceError` 400 elsewhere. `storeId` mode is **not** read-only: it can create carts, run a checkout to a real order, and register/log in customers; what it cannot reach is the admin surface.
|
|
31
31
|
|
|
32
32
|
> **Building a storefront?** You only need your **Sales Channel ID** (`vc_*`) from the Brainerce dashboard under **Sales Channels**. No API key needed. API keys are a server-side admin secret.
|
|
33
33
|
>
|
|
34
|
-
> `connectionId` is the deprecated alias of `salesChannelId`. It still works
|
|
34
|
+
> `connectionId` is the deprecated alias of `salesChannelId`. It still works and logs a deprecation warning on every construction — it is a permanent backward-compat alias, not scheduled for removal. Use `salesChannelId` in new code.
|
|
35
35
|
|
|
36
36
|
## Installation
|
|
37
37
|
|
|
@@ -47,7 +47,7 @@ yarn add brainerce
|
|
|
47
47
|
|
|
48
48
|
## What You Must Build
|
|
49
49
|
|
|
50
|
-
Every Brainerce storefront must include **all mandatory features** below. Features auto-hide when the underlying capability is disabled, so build them all anyway
|
|
50
|
+
Every Brainerce storefront must include **all mandatory features** below. Features auto-hide when the underlying capability is disabled, so build them all anyway; they'll appear the moment the store owner enables them.
|
|
51
51
|
|
|
52
52
|
| Feature | SDK entry point | Mandatory |
|
|
53
53
|
| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- |
|
|
@@ -55,7 +55,7 @@ Every Brainerce storefront must include **all mandatory features** below. Featur
|
|
|
55
55
|
| Product detail with variant picker, stock, price | `client.getProductBySlug()` + helpers | ✅ |
|
|
56
56
|
| Buyer customization fields (engraving, uploads, select) | `product.customizationFields`, `client.uploadCustomizationFile()` | ✅ |
|
|
57
57
|
| Cart (add, update, remove, coupon, totals) | `client.addToCart()`, `getCartTotals(cart)` | ✅ |
|
|
58
|
-
| Inventory reservation countdown | Cart expiry timestamp from `client.getCart()`
|
|
58
|
+
| Inventory reservation countdown | Cart expiry timestamp from `client.getCart(cartId)` | ✅ |
|
|
59
59
|
| Full checkout end-to-end with payment | `setShippingAddress → selectShippingMethod → getPaymentProviders → pay → handlePaymentSuccess → waitForOrder` | ✅ |
|
|
60
60
|
| Order confirmation (clear cart + wait for real order) | `client.handlePaymentSuccess()`, `client.waitForOrder()` | ✅ |
|
|
61
61
|
| Register + email verification flow | `client.registerCustomer()`, `client.verifyEmail()` | ✅ |
|
|
@@ -66,7 +66,7 @@ Every Brainerce storefront must include **all mandatory features** below. Featur
|
|
|
66
66
|
| Loyalty & rewards (points balance + tiers + redeem) | `client.getLoyaltyStatus()`, `client.getAvailableRewards()`, `client.getRecommendedReward()`, `client.redeemLoyaltyReward(id)`, `client.reportSocialShare()` | conditional |
|
|
67
67
|
| Loyalty paid membership (premium subscription) | `client.getMembershipPlans()`, `client.getMySavedPaymentMethods()`, `client.subscribeToMembership(params)`, `client.cancelMembership()` | conditional |
|
|
68
68
|
| Embeddable loyalty widget (points + rewards on ANY site) | `client.getLoyaltyWidgetSession()` | conditional |
|
|
69
|
-
| Global header: cart count + search autocomplete | `client.
|
|
69
|
+
| Global header: cart count + search autocomplete | `client.smartGetCart()`, `client.getSearchSuggestions(query)` | ✅ |
|
|
70
70
|
| Discount banners + product badges | `client.getDiscountBanners()`, `client.getProductDiscountBadge(productId)` | ✅ |
|
|
71
71
|
| Product reviews on PDP + JSON-LD aggregateRating | `client.listProductReviews(id)`, `client.submitProductReview(id, …)` | ✅ |
|
|
72
72
|
| Customer photos on reviews | `client.uploadReviewPhoto(productId, file)`, then `imageKeys` on submit | conditional |
|
|
@@ -85,14 +85,14 @@ Violating any of these causes production incidents or broken orders. Read them b
|
|
|
85
85
|
|
|
86
86
|
- ALWAYS call SDK client methods. Never reconstruct REST URLs or call `fetch` directly.
|
|
87
87
|
- NEVER invent SDK method names. If it's not in this README or in `get-sdk-docs`, it doesn't exist.
|
|
88
|
-
- NEVER hardcode product data, categories, or store copy
|
|
89
|
-
- NEVER use `submitGuestOrder()`, `createGuestOrder()` or `createOrder()` on a store that takes payment
|
|
88
|
+
- NEVER hardcode product data, categories, or store copy. Brainerce is the database.
|
|
89
|
+
- NEVER use `submitGuestOrder()`, `createGuestOrder()` or `createOrder()` on a store that takes payment. They `POST /orders` directly, never touch `/payment/intent`, and produce an order nobody has paid for. They exist only for cash-on-delivery, manual-invoice and sandbox stores. Everything else goes through the checkout sequence below.
|
|
90
90
|
- ALWAYS use SDK helpers (`getCartTotals`, `formatPrice`, `getProductPriceInfo`, `getCartItemImage`, `getCartItemName`, `getVariantPrice`, `getStockStatus`, `getDescriptionContent`) instead of reading raw fields.
|
|
91
91
|
|
|
92
92
|
### State management
|
|
93
93
|
|
|
94
94
|
- The SDK manages cart, checkout, and session state. Do NOT duplicate it in your own Redux/context.
|
|
95
|
-
- Product lists, categories, and inventory counts are NOT client state
|
|
95
|
+
- Product lists, categories, and inventory counts are NOT client state; fetch on demand.
|
|
96
96
|
- Discount rules and coupon validity are evaluated server-side. Never re-implement them client-side.
|
|
97
97
|
|
|
98
98
|
### Authentication
|
|
@@ -106,32 +106,32 @@ Violating any of these causes production incidents or broken orders. Read them b
|
|
|
106
106
|
### Checkout & orders
|
|
107
107
|
|
|
108
108
|
- The checkout sequence is strict: `setShippingAddress` → pick a shipping rate → `getPaymentProviders` → provider payment → `handlePaymentSuccess` → `waitForOrder`. Never skip or reorder.
|
|
109
|
-
- ALWAYS call `handlePaymentSuccess(checkoutId)` on the confirmation page
|
|
109
|
+
- ALWAYS call `handlePaymentSuccess(checkoutId)` on the confirmation page. It clears the cart so users don't see stale items.
|
|
110
110
|
- ALWAYS call `waitForOrder(checkoutId)` to poll for the real order before showing an order number. The payment callback may return before the order record exists.
|
|
111
|
-
- NEVER use the checkout total as the cart total
|
|
112
|
-
- The reservation timer is a hard guarantee
|
|
111
|
+
- NEVER use the checkout total as the cart total; they diverge (tax, shipping, discounts). Display `checkout.lineItems` on the summary, not `cart.items`.
|
|
112
|
+
- The reservation timer is a hard guarantee. Display the countdown from the cart and let the SDK handle expiry.
|
|
113
113
|
|
|
114
114
|
### Token handling
|
|
115
115
|
|
|
116
|
-
- Customer auth tokens (`result.token` from `loginCustomer`/`registerCustomer`) should be passed to `client.setCustomerToken(token)`. The SDK stores session state internally. `setCustomerToken` is a plain setter
|
|
116
|
+
- Customer auth tokens (`result.token` from `loginCustomer`/`registerCustomer`) should be passed to `client.setCustomerToken(token)`. The SDK stores session state internally. `setCustomerToken` is a plain setter, so always follow it with `await client.syncCartOnLogin()`, or the shopper's guest cart is never attached to their account and identity-keyed features (first-order discounts, per-customer usage caps, abandoned-cart recovery) misbehave.
|
|
117
117
|
- NEVER put the admin API key (`brainerce_*`) in client code. It is a server-only secret.
|
|
118
118
|
- OAuth callbacks arrive with a one-time `auth_code` URL param. Call `client.exchangeOAuthCode(authCode)` to swap it for the JWT, apply via `setCustomerToken`, then call `client.syncCartOnLogin()` to claim the guest cart. (The legacy `?token=` URL param is still emitted for backward compatibility but will be removed in the next major release.)
|
|
119
119
|
|
|
120
120
|
### i18n
|
|
121
121
|
|
|
122
|
-
- NEVER hardcode currency, locale, or language strings
|
|
123
|
-
- NEVER format prices with `toFixed(2)
|
|
124
|
-
- When i18n is enabled, call `client.setLocale(locale)` at app init and include a language switcher. For RTL locales (`he`, `ar`), set `<html dir="rtl">`
|
|
125
|
-
- Call `setLocale()` **before `createCheckout()
|
|
122
|
+
- NEVER hardcode currency, locale, or language strings; read them from `getStoreInfo()`.
|
|
123
|
+
- NEVER format prices with `toFixed(2)`; use `formatPrice()` from the SDK.
|
|
124
|
+
- When i18n is enabled, call `client.setLocale(locale)` at app init and include a language switcher. For RTL locales (`he`, `ar`), set `<html dir="rtl">` and do NOT add `flex-row-reverse` on top.
|
|
125
|
+
- Call `setLocale()` **before `createCheckout()`**, because the order captures the active locale (`order.locale`), which drives the confirmation-email language AND the product names shown in order history. Without it, orders and their emails fall back to the store default language. Search also needs the locale active to match translated names.
|
|
126
126
|
- Built-in order emails are localized for English and Hebrew; for other languages the merchant must add a custom email template per language (otherwise the email falls back to English).
|
|
127
127
|
|
|
128
128
|
### Type safety
|
|
129
129
|
|
|
130
130
|
- NEVER use `as any` or `as unknown as`. Fix the type, don't hide it.
|
|
131
131
|
- NEVER write your own copies of SDK types (Cart, Product, Order). Import from `'brainerce'`.
|
|
132
|
-
- All prices are **STRINGS
|
|
132
|
+
- All prices are **STRINGS**, so always `parseFloat()` before math or comparisons.
|
|
133
133
|
- `CartItem` / `CheckoutLineItem` = **NESTED** (`item.product.name`, `item.unitPrice`). `OrderItem` = **FLAT** (`item.name`, `item.price`). Not interchangeable.
|
|
134
|
-
- `Cart` has no `.total` field
|
|
134
|
+
- `Cart` has no `.total` field; call `getCartTotals(cart)`.
|
|
135
135
|
|
|
136
136
|
---
|
|
137
137
|
|
|
@@ -141,7 +141,7 @@ These sequences are non-negotiable. The order of SDK calls matters.
|
|
|
141
141
|
|
|
142
142
|
### Checkout flow
|
|
143
143
|
|
|
144
|
-
1. Collect customer email, billing address, shipping address (`line1`, `line2`, `city`, `region`, `postalCode`, `country`). `email` is required. Include an optional **"Order notes"** textarea by default
|
|
144
|
+
1. Collect customer email, billing address, shipping address (`line1`, `line2`, `city`, `region`, `postalCode`, `country`). `email` is required. Include an optional **"Order notes"** textarea by default; its value lands on the order for the merchant.
|
|
145
145
|
2. Submit address to get shipping rates:
|
|
146
146
|
```ts
|
|
147
147
|
const { checkout, rates } = await client.setShippingAddress(checkoutId, {
|
|
@@ -162,17 +162,17 @@ These sequences are non-negotiable. The order of SDK calls matters.
|
|
|
162
162
|
await client.selectShippingMethod(checkoutId, rateId);
|
|
163
163
|
```
|
|
164
164
|
Label each rate with `rate.speedTier` (`'cheapest' | 'balanced' | 'fastest'`) and
|
|
165
|
-
`rate.estimatedDays
|
|
165
|
+
`rate.estimatedDays`, **not** `rate.name`, which for live carrier rates is the
|
|
166
166
|
carrier's own service code. Manual zone rates carry no `speedTier`; use their `name`.
|
|
167
167
|
4. Fetch available payment providers:
|
|
168
168
|
```ts
|
|
169
169
|
const providers = await client.getPaymentProviders();
|
|
170
170
|
```
|
|
171
|
-
Each provider has a `renderType
|
|
171
|
+
Each provider has a `renderType`: `'sdk-widget'` (Stripe, PayPal, Grow), `'iframe'` (Cardcom), `'redirect'`, `'sandbox'`. Branch on `renderType`, never on provider name.
|
|
172
172
|
5. Confirm payment using the provider's flow (Stripe Elements `stripe.confirmCardPayment`, PayPal button, redirect, etc.).
|
|
173
173
|
6. On the confirmation page, **always call both**:
|
|
174
174
|
```ts
|
|
175
|
-
|
|
175
|
+
client.handlePaymentSuccess(checkoutId); // synchronous, clears cart. Do NOT await it.
|
|
176
176
|
const order = await client.waitForOrder(checkoutId); // polls until order exists
|
|
177
177
|
```
|
|
178
178
|
7. Display `checkout.lineItems` (not `cart.items`) on the order summary.
|
|
@@ -192,7 +192,7 @@ These sequences are non-negotiable. The order of SDK calls matters.
|
|
|
192
192
|
4. On verify-email: collect 6-digit code → `client.verifyEmail(code)`. Offer resend via `client.resendVerificationEmail()`.
|
|
193
193
|
5. After `verifyEmail` resolves: `client.setCustomerToken(result.token)`, then `await client.syncCartOnLogin()`, route to account.
|
|
194
194
|
|
|
195
|
-
> Build the verify-email step even if verification is currently disabled
|
|
195
|
+
> Build the verify-email step even if verification is currently disabled; it auto-hides.
|
|
196
196
|
|
|
197
197
|
### Login flow
|
|
198
198
|
|
|
@@ -204,16 +204,32 @@ These sequences are non-negotiable. The order of SDK calls matters.
|
|
|
204
204
|
3. Branch on `result.requiresVerification`:
|
|
205
205
|
- `true` → route to verify-email
|
|
206
206
|
- `false` → `client.setCustomerToken(result.token)`, then `await client.syncCartOnLogin()`, route to previous page or account
|
|
207
|
-
4. Always offer OAuth buttons from `client.getAvailableOAuthProviders()
|
|
208
|
-
5. Render specific errors (bad credentials, rate limited, disabled)
|
|
207
|
+
4. Always offer OAuth buttons from `client.getAvailableOAuthProviders()`. Render the region even when empty; it auto-populates when a provider is enabled.
|
|
208
|
+
5. Render specific errors (bad credentials, rate limited, disabled). Never swallow them.
|
|
209
209
|
|
|
210
210
|
### Order confirmation flow
|
|
211
211
|
|
|
212
212
|
1. Read `checkoutId` from URL or session.
|
|
213
|
-
2. `
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
213
|
+
2. `client.handlePaymentSuccess(checkoutId)` is mandatory. It clears the cart so purchased items don't show on the next visit. It is **synchronous** and returns a plain object, not a promise, so awaiting it is a no-op that only looks like it did something:
|
|
214
|
+
|
|
215
|
+
```ts
|
|
216
|
+
const { cleared, mode, userType, itemsRemoved } = client.handlePaymentSuccess(checkoutId);
|
|
217
|
+
// mode: 'full' the whole cart went, the normal case
|
|
218
|
+
// mode: 'partial' a partial checkout, so only the purchased lines went.
|
|
219
|
+
// `itemsRemoved` counts them and the rest stay in the cart
|
|
220
|
+
// mode: 'none' nothing to do, because this checkout was already handled
|
|
221
|
+
// in this browser session. React Strict Mode runs effects
|
|
222
|
+
// twice and a refresh re-runs the page, so this is the
|
|
223
|
+
// normal repeat-call answer: SUCCESS, not a failure.
|
|
224
|
+
// `cleared` is false here. Never show an error on it.
|
|
225
|
+
// userType: 'guest' | 'customer'
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Branch on `mode` only for what you render: a `'partial'` result means the shopper still has a cart worth linking to. Never gate `waitForOrder` on `cleared`.
|
|
229
|
+
|
|
230
|
+
3. `const result = await client.waitForOrder(checkoutId)` polls until the webhook writes the order. `result.status.orderNumber` / `result.status.orderId` are available on success.
|
|
231
|
+
4. Show a spinner during step 3 (webhook may lag). On timeout: show "we're still processing, check your email" with a link to order history, where the order WILL appear.
|
|
232
|
+
5. On success: render the order number, or, if your design wants more than that, fetch full details:
|
|
217
233
|
|
|
218
234
|
```typescript
|
|
219
235
|
const result = await client.waitForOrder(checkoutId);
|
|
@@ -225,7 +241,7 @@ if (result.success) {
|
|
|
225
241
|
}
|
|
226
242
|
```
|
|
227
243
|
|
|
228
|
-
`getOrderByCheckout` works for guests too
|
|
244
|
+
`getOrderByCheckout` works for guests too, because possession of the checkout id is
|
|
229
245
|
the credential, no customer token needed.
|
|
230
246
|
|
|
231
247
|
### Password reset flow
|
|
@@ -259,7 +275,7 @@ the credential, no customer token needed.
|
|
|
259
275
|
// then redirect to account
|
|
260
276
|
}
|
|
261
277
|
```
|
|
262
|
-
The legacy `?token=` URL param is still emitted for backward compatibility but will be removed in the next major release
|
|
278
|
+
The legacy `?token=` URL param is still emitted for backward compatibility but will be removed in the next major release. Migrate to `auth_code` now.
|
|
263
279
|
4. On failure the browser lands on **the same `redirectUrl`** (never on the API host), carrying `oauth_error` + `error_description`:
|
|
264
280
|
```ts
|
|
265
281
|
const oauthError = params.get('oauth_error') as OAuthErrorCode | null;
|
|
@@ -273,16 +289,16 @@ the credential, no customer token needed.
|
|
|
273
289
|
}
|
|
274
290
|
}
|
|
275
291
|
```
|
|
276
|
-
The code list is open
|
|
292
|
+
The code list is open: the provider's own codes (`access_denied`, …) pass through, so always handle the default case.
|
|
277
293
|
|
|
278
294
|
> Build the OAuth button region AND the callback handler even when no providers are configured.
|
|
279
295
|
|
|
280
296
|
### Inventory reservation flow
|
|
281
297
|
|
|
282
|
-
- Display the countdown from `cart.reservation?.expiresAt
|
|
283
|
-
- On expiry: call `client.getCart()` to refresh. Items whose reservation expired are flagged server-side.
|
|
298
|
+
- Display the countdown from `cart.reservation?.expiresAt`, refreshing once per second (`reservation` is optional; only present when a reservation strategy is active).
|
|
299
|
+
- On expiry: call `client.getCart(cartId)` to refresh, or `client.smartGetCart()` when you are not tracking a cart id yourself. `getCart` takes the cart id; there is no no-argument form. Items whose reservation expired are flagged server-side.
|
|
284
300
|
- On the checkout page: if reservation has expired, block payment and show "your cart has expired" with a link back to cart.
|
|
285
|
-
- Do NOT implement your own timer logic
|
|
301
|
+
- Do NOT implement your own timer logic; the SDK is the source of truth.
|
|
286
302
|
|
|
287
303
|
---
|
|
288
304
|
|
|
@@ -290,33 +306,33 @@ the credential, no customer token needed.
|
|
|
290
306
|
|
|
291
307
|
The SDK exports these utility functions for common UI tasks:
|
|
292
308
|
|
|
293
|
-
| Function | Purpose
|
|
294
|
-
| ---------------------------------------------- |
|
|
295
|
-
| `formatPrice(amount, { currency?, locale? })` | Format prices for display
|
|
296
|
-
| `getPriceDisplay(amount, currency?, locale?)` | Alias for `formatPrice`
|
|
297
|
-
| `getDescriptionContent(product)` | Get product description (HTML or text)
|
|
298
|
-
| `isHtmlDescription(product)` | Check if description is HTML
|
|
299
|
-
| `getStockStatus(inventory)` | Get human-readable stock status
|
|
300
|
-
| `getProductPrice(product)` | Get effective price (handles sales)
|
|
301
|
-
| `getProductPriceInfo(product)` | Get price + sale info + discount % (falls back to `priceMin` when `basePrice=0` on VARIABLE)
|
|
302
|
-
| `getVariantPrice(variant, basePrice)` | Get variant price with fallback
|
|
303
|
-
| `getCartTotals(cart, shippingPrice?)` | Calculate cart subtotal/discount/total
|
|
304
|
-
| `getCartItemName(item)` | Get name from nested cart item (product + variant)
|
|
305
|
-
| `getCartItemImage(item)` | Get image URL from cart item
|
|
306
|
-
| `getVariantOptions(variant)` | Get variant attributes as array
|
|
307
|
-
| `isCouponApplicableToProduct(coupon, product)` | Check if coupon applies
|
|
308
|
-
| `isAllowedPaymentUrl(url, options?)` | Validate a payment URL host
|
|
309
|
-
| `safePaymentRedirect(url, options?)` | Validate then `window.location.href`
|
|
310
|
-
| `buildProductJsonLd(product, opts)` | schema.org Product JSON-LD (PDPs only)
|
|
311
|
-
| `buildArticleJsonLd(post, opts)` | schema.org Article JSON-LD for blog posts
|
|
312
|
-
| `buildOrganizationJsonLd(store, opts)` | schema.org Organization for the homepage
|
|
313
|
-
| `buildBreadcrumbJsonLd(items)` | schema.org BreadcrumbList
|
|
314
|
-
| `buildProductFaqJsonLd(product)` | schema.org FAQPage from `product.faq` (null when empty)
|
|
315
|
-
| `jsonLdScriptProps(data)` | XSS-safe `<script type="application/ld+json">` props
|
|
316
|
-
| `getBlogSitemapEntries(client, opts)` | Paginate published posts into sitemap entries
|
|
317
|
-
| `getProductSitemapEntries(client, opts)` | ALL published products into sitemap entries (no 100-item clamp)
|
|
318
|
-
| `getCategorySitemapEntries(client, opts)` | Category tree into sitemap entries
|
|
319
|
-
| `client.resolveSlugRedirect(type, slug)` | Renamed slug → current slug (301 support in not-found paths)
|
|
309
|
+
| Function | Purpose | Example |
|
|
310
|
+
| ---------------------------------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
|
|
311
|
+
| `formatPrice(amount, { currency?, locale? })` | Format prices for display | `formatPrice("99.99", { currency: 'USD' })` → `$99.99` |
|
|
312
|
+
| `getPriceDisplay(amount, currency?, locale?)` | Alias for `formatPrice` | Same as above |
|
|
313
|
+
| `getDescriptionContent(product)` | Get product description (HTML or text) | `getDescriptionContent(product)` |
|
|
314
|
+
| `isHtmlDescription(product)` | Check if description is HTML | `isHtmlDescription(product)` → `true/false` |
|
|
315
|
+
| `getStockStatus(inventory)` | Get human-readable stock status | `getStockStatus(inventory)` → `"In Stock"` |
|
|
316
|
+
| `getProductPrice(product)` | Get effective price (handles sales) | `getProductPrice(product)` → `29.99` |
|
|
317
|
+
| `getProductPriceInfo(product)` | Get price + sale info + discount % (falls back to `priceMin` when `basePrice=0` on VARIABLE) | `{ price, isOnSale, discountPercent }` |
|
|
318
|
+
| `getVariantPrice(variant, basePrice)` | Get variant price with fallback | `getVariantPrice(variant, '29.99')` → `34.99` |
|
|
319
|
+
| `getCartTotals(cart, shippingPrice?)` | Calculate cart subtotal/discount/total | `{ subtotal, discount, shipping, total }` |
|
|
320
|
+
| `getCartItemName(item)` | Get name from nested cart item (product + variant) | `getCartItemName(item)` → `"Blue T-Shirt - Large"` |
|
|
321
|
+
| `getCartItemImage(item)` | Get image URL from cart item | `getCartItemImage(item)` → `"https://..."` |
|
|
322
|
+
| `getVariantOptions(variant)` | Get variant attributes as array | `[{ name: "Color", value: "Red" }]` |
|
|
323
|
+
| `isCouponApplicableToProduct(coupon, product)` | Check if coupon applies | `isCouponApplicableToProduct(coupon, product)` |
|
|
324
|
+
| `isAllowedPaymentUrl(url, options?)` | Validate a payment URL host | `isAllowedPaymentUrl(intent.clientSecret)` → `true` |
|
|
325
|
+
| `safePaymentRedirect(url, options?)` | Validate then `window.location.href` | `safePaymentRedirect(intent.clientSecret)` |
|
|
326
|
+
| `buildProductJsonLd(product, opts)` | schema.org Product JSON-LD (PDPs only) | See SEO section |
|
|
327
|
+
| `buildArticleJsonLd(post, opts)` | schema.org Article JSON-LD for blog posts | See SEO section |
|
|
328
|
+
| `buildOrganizationJsonLd(store, opts)` | schema.org Organization for the homepage | See SEO section |
|
|
329
|
+
| `buildBreadcrumbJsonLd(items)` | schema.org BreadcrumbList | See SEO section |
|
|
330
|
+
| `buildProductFaqJsonLd(product)` | schema.org FAQPage from `product.faq` (null when empty); render the same pairs as visible text | `const faq = buildProductFaqJsonLd(product)` |
|
|
331
|
+
| `jsonLdScriptProps(data)` | XSS-safe `<script type="application/ld+json">` props | `<script {...jsonLdScriptProps(data)} />` |
|
|
332
|
+
| `getBlogSitemapEntries(client, opts)` | Paginate published posts into sitemap entries | See SEO section |
|
|
333
|
+
| `getProductSitemapEntries(client, opts)` | ALL published products into sitemap entries (no 100-item clamp) | See SEO section |
|
|
334
|
+
| `getCategorySitemapEntries(client, opts)` | Category tree into sitemap entries | See SEO section |
|
|
335
|
+
| `client.resolveSlugRedirect(type, slug)` | Renamed slug → current slug (301 support in not-found paths) | See SEO section |
|
|
320
336
|
|
|
321
337
|
```typescript
|
|
322
338
|
import {
|
|
@@ -399,7 +415,7 @@ const { data: products } = await client.getProducts();
|
|
|
399
415
|
|
|
400
416
|
### Product customization fields (buyer input)
|
|
401
417
|
|
|
402
|
-
Products can expose `customizationFields
|
|
418
|
+
Products can expose `customizationFields`, the merchant-defined inputs the buyer fills on the product page (engraving text, photo upload, select / multi-select options, date pickers, etc.). Render the form from the array, upload any images via `uploadCustomizationFile()`, then pass values as `metadata` on add-to-cart. The server validates and snapshots everything onto the order line. Definitions flagged `appliesToAllProducts: true` are folded into every product's `customizationFields` automatically, with no client-side merging required.
|
|
403
419
|
|
|
404
420
|
```typescript
|
|
405
421
|
if (product.customizationFields?.length) {
|
|
@@ -426,7 +442,7 @@ Full rendering guide + per-type validation rules: [Core Integration §2.8](https
|
|
|
426
442
|
|
|
427
443
|
### Modifier groups (restaurant / build-your-own products)
|
|
428
444
|
|
|
429
|
-
Products can expose `modifierGroups
|
|
445
|
+
Products can expose `modifierGroups`, the merchant-defined option blocks like "Toppings" (max 8, first 3 free) or "Sauce" (pick exactly one). Render radios for `selectionType: 'SINGLE'` and checkboxes for `'MULTIPLE'`, honor `defaultModifierIds` and `isDefault` on first render, disable modifiers with `available: false`, and pass selections on add-to-cart. The server is the source of truth for free-allocation and final pricing.
|
|
430
446
|
|
|
431
447
|
```typescript
|
|
432
448
|
// 5-line add-to-cart with modifiers
|
|
@@ -440,7 +456,7 @@ await client.addToCart(cart.id, {
|
|
|
440
456
|
});
|
|
441
457
|
```
|
|
442
458
|
|
|
443
|
-
Money on the wire is **always strings** (`priceDelta: "5.00"`). Validation failures arrive as a structured 400 envelope on `BrainerceError.details` with `code: 'MODIFIER_VALIDATION_FAILED'`; the per-issue list is nested at `details.errors[]`, so from the SDK it reads `err.details.details.errors` (`err.details` is the whole response body)
|
|
459
|
+
Money on the wire is **always strings** (`priceDelta: "5.00"`). Validation failures arrive as a structured 400 envelope on `BrainerceError.details` with `code: 'MODIFIER_VALIDATION_FAILED'`; the per-issue list is nested at `details.errors[]`, so from the SDK it reads `err.details.details.errors` (`err.details` is the whole response body). See INTEGRATION-RULES.md "Modifier validation errors" for the full code list.
|
|
444
460
|
|
|
445
461
|
Full rendering guide: [Core Integration §2.9](https://brainerce.com/docs/integration/core). Restaurant features (scheduled availability, nested combos to depth 3, downsell modifiers): [Optional Features "Restaurant / build-your-own products"](https://brainerce.com/docs/integration/optional).
|
|
446
462
|
|
|
@@ -464,22 +480,22 @@ const page = await client.content.page.getBySlug(params.slug, locale);
|
|
|
464
480
|
if (!page) notFound();
|
|
465
481
|
```
|
|
466
482
|
|
|
467
|
-
All `get` / `getBySlug` return `null` on 404
|
|
483
|
+
All `get` / `getBySlug` return `null` on 404. Render a hard-coded fallback so the page never crashes when the merchant hasn't seeded yet.
|
|
468
484
|
|
|
469
|
-
**Security**: `FAQ.items[i].answer`, `RICH_TEXT.html`, `PAGE.html`, and `Product.description` are **merchant-authored HTML**. The server does NOT pre-sanitize FAQ/RICH_TEXT/PAGE (merchants may embed iframes); `Product.description` is server-sanitized on write but you still sanitize on render. `Product.description` may contain `<video>` and host-locked YouTube/Vimeo `<iframe>` embeds
|
|
485
|
+
**Security**: `FAQ.items[i].answer`, `RICH_TEXT.html`, `PAGE.html`, and `Product.description` are **merchant-authored HTML**. The server does NOT pre-sanitize FAQ/RICH_TEXT/PAGE (merchants may embed iframes); `Product.description` is server-sanitized on write but you still sanitize on render. `Product.description` may contain `<video>` and host-locked YouTube/Vimeo `<iframe>` embeds, so allow those tags (iframe restricted to `www.youtube.com` / `www.youtube-nocookie.com` / `player.vimeo.com`) and add those hosts to your CSP `frame-src`. ALWAYS sanitize before injecting:
|
|
470
486
|
|
|
471
487
|
```typescript
|
|
472
488
|
import DOMPurify from 'isomorphic-dompurify';
|
|
473
489
|
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(rawHtml) }} />
|
|
474
490
|
```
|
|
475
491
|
|
|
476
|
-
The `create-brainerce-store` scaffold ships ready-made components (`<AnnouncementBar>`, `<SiteHeader>`, `<SiteFooter>`, `<FaqSection>`, `<RichTextBlock>`) + a `/pages/[slug]` catch-all route
|
|
492
|
+
The `create-brainerce-store` scaffold ships ready-made components (`<AnnouncementBar>`, `<SiteHeader>`, `<SiteFooter>`, `<FaqSection>`, `<RichTextBlock>`) + a `/pages/[slug]` catch-all route. Use them rather than rolling your own renderers.
|
|
477
493
|
|
|
478
494
|
Full guide: [Core Integration "Content"](https://brainerce.com/docs/integration/core#content). Advanced patterns (channel scoping, custom fields, translations, admin writes): [Optional Features "Content"](https://brainerce.com/docs/integration/optional). Validation + sanitize rules: [Rules & Reference "Content"](https://brainerce.com/docs/integration/rules).
|
|
479
495
|
|
|
480
496
|
### Blog
|
|
481
497
|
|
|
482
|
-
Merchants publish blog posts in **Content → Blog**. Storefronts choose their own URL scheme
|
|
498
|
+
Merchants publish blog posts in **Content → Blog**. Storefronts choose their own URL scheme: render posts at `/blog/[slug]`, `/articles/[slug]`, or whatever fits the brand.
|
|
483
499
|
|
|
484
500
|
```typescript
|
|
485
501
|
// List published posts (any SDK mode)
|
|
@@ -504,11 +520,11 @@ return <div dangerouslySetInnerHTML={{ __html: safeHtml }} className="prose" />;
|
|
|
504
520
|
|
|
505
521
|
**Scheduling**: A post is visible once `status === 'PUBLISHED'` and `publishedAt <= now()`. Set a future `publishedAt` when publishing to schedule.
|
|
506
522
|
|
|
507
|
-
**SEO Autopilot writes here too**: the platform's SEO Autopilot publishes AI-written articles into this same blog automatically
|
|
523
|
+
**SEO Autopilot writes here too**: the platform's SEO Autopilot publishes AI-written articles into this same blog automatically. Render whatever `getPosts()` returns, and see the SEO section below for the required discoverability pieces.
|
|
508
524
|
|
|
509
|
-
### SEO
|
|
525
|
+
### SEO: JSON-LD builders, sitemap helpers, IndexNow key, llms.txt + agents.md
|
|
510
526
|
|
|
511
|
-
The SDK ships schema.org builders that encode Google's structured-data rules (aggregateRating gated on `reviewCount > 0` with explicit `bestRating`/`worstRating`, AggregateOffer with `offerCount` for VARIABLE products, XSS-safe serialization, and the full availability mapping
|
|
527
|
+
The SDK ships schema.org builders that encode Google's structured-data rules (aggregateRating gated on `reviewCount > 0` with explicit `bestRating`/`worstRating`, AggregateOffer with `offerCount` for VARIABLE products, XSS-safe serialization, and the full availability mapping: `InStock` from the backend's pre-computed `inventory.inStock`, `BackOrder` for purchasable-while-out-of-stock products, `OutOfStock` otherwise). `buildProductJsonLd`'s Offer also always includes `itemCondition` (hardcoded `NewCondition`, for a first-party new-goods catalog), `priceValidUntil` when the product has an active sale-price window (`salePriceEndsAt`), and `shippingDetails` when you pass `shipping` (real flat-rate/free zones from `storeInfo.shipping`, omitted entirely and never fabricated if you don't pass it). Prefer these builders over hand-rolled JSON-LD:
|
|
512
528
|
|
|
513
529
|
```tsx
|
|
514
530
|
import {
|
|
@@ -538,7 +554,7 @@ import {
|
|
|
538
554
|
}))} />
|
|
539
555
|
```
|
|
540
556
|
|
|
541
|
-
**Product + category + blog entries in sitemap.xml** (required). ⚠️ Products **must** use `getProductSitemapEntries
|
|
557
|
+
**Product + category + blog entries in sitemap.xml** (required). ⚠️ Products **must** use `getProductSitemapEntries`, because the public listing API clamps `limit` to 100, so a naive `getProducts({ limit: 1000 })` sitemap silently truncates at 100 products. The helper uses a dedicated lightweight endpoint (slug + updatedAt + localeSlugs, up to 5000 in one call) and falls back to pagination on older backends:
|
|
542
558
|
|
|
543
559
|
```ts
|
|
544
560
|
// app/sitemap.ts
|
|
@@ -566,15 +582,15 @@ const blogPages = await getBlogSitemapEntries(client, {
|
|
|
566
582
|
return [...staticPages, ...productPages, ...categoryPages, ...blogPages];
|
|
567
583
|
```
|
|
568
584
|
|
|
569
|
-
**robots.txt** (required): allow the AI search crawlers by name (`OAI-SearchBot`, `ChatGPT-User`, `Claude-SearchBot`, `Claude-User`, `PerplexityBot`, `Perplexity-User`, `Bingbot`, `Applebot`, `Amazonbot`)
|
|
585
|
+
**robots.txt** (required): allow the AI search crawlers by name (`OAI-SearchBot`, `ChatGPT-User`, `Claude-SearchBot`, `Claude-User`, `PerplexityBot`, `Perplexity-User`, `Bingbot`, `Applebot`, `Amazonbot`); they power ChatGPT/Claude/Perplexity/Copilot shopping answers and respect robots.txt. Keep `/api/`, `/auth/`, `/checkout/`, `/account/` disallowed.
|
|
570
586
|
|
|
571
587
|
**IndexNow key file** (required): the platform pings IndexNow when posts publish; search engines verify by fetching `GET /indexnow-key.txt`. Serve `getStoreInfo().seo.indexNowKey` as `text/plain`, 404 while `null`. The key is **not a secret** (public by protocol design).
|
|
572
588
|
|
|
573
|
-
**llms.txt + agents.md** (required): `/llms.txt` is a plain-text site summary (store name, categories, key pages, recent article links) for AI answer engines; `/agents.md` is the agent-facing guide (machine surfaces, key URLs, currency, how buying works). Multi-locale stores: keep these dotted routes (plus `indexnow-key.txt`) at the app ROOT
|
|
589
|
+
**llms.txt + agents.md** (required): `/llms.txt` is a plain-text site summary (store name, categories, key pages, recent article links) for AI answer engines; `/agents.md` is the agent-facing guide (machine surfaces, key URLs, currency, how buying works). Multi-locale stores: keep these dotted routes (plus `indexnow-key.txt`) at the app ROOT, because locale middleware matchers skip dotted paths, so a locale-nested copy serves the homepage HTML instead.
|
|
574
590
|
|
|
575
591
|
**Site verification** : when `getStoreInfo().seo.googleSiteVerification` is set, render `<meta name="google-site-verification" content={token} />` in the root layout head (Search Console verification + Merchant Center website claim).
|
|
576
592
|
|
|
577
|
-
**Renamed slugs 301 instead of 404** (required): the platform records every product/blog slug rename. In the not-found path of the product and blog pages call `client.resolveSlugRedirect('product' | 'blog', slug)
|
|
593
|
+
**Renamed slugs 301 instead of 404** (required): the platform records every product/blog slug rename. In the not-found path of the product and blog pages call `client.resolveSlugRedirect('product' | 'blog', slug)`. On a hit, `permanentRedirect()` to the returned `currentSlug`; `null` means a genuine 404 (never throws, safe to call unconditionally). Rename chains collapse to one hop.
|
|
578
594
|
|
|
579
595
|
---
|
|
580
596
|
|
|
@@ -769,7 +785,7 @@ const address: SetShippingAddressDto = {
|
|
|
769
785
|
```
|
|
770
786
|
|
|
771
787
|
**And no coordinates.** The address endpoints validate against a strict
|
|
772
|
-
allow-list
|
|
788
|
+
allow-list: one property that isn't on the DTO rejects the whole call with
|
|
773
789
|
`400 "property lat should not exist"`, which doesn't degrade: it blocks the
|
|
774
790
|
address step for every shopper. `getAddressDetails()` resolves an address
|
|
775
791
|
carrying `lat`, `lng` and `formattedAddress`, so this is the one that bites:
|
|
@@ -795,8 +811,8 @@ await client.setShippingAddress(checkoutId, {
|
|
|
795
811
|
});
|
|
796
812
|
```
|
|
797
813
|
|
|
798
|
-
Use `address.lat` / `address.lng` for your own UI
|
|
799
|
-
readout
|
|
814
|
+
Use `address.lat` / `address.lng` for your own UI (a map pin, a distance
|
|
815
|
+
readout) and nothing else.
|
|
800
816
|
|
|
801
817
|
### 10. OAuth - Use `authorizationUrl`, NOT `url`
|
|
802
818
|
|
|
@@ -875,7 +891,7 @@ const total = subtotal - discount;
|
|
|
875
891
|
- Cart field is `discountAmount`, NOT `discount`
|
|
876
892
|
- Cart has NO `total` field - use `getCartTotals()` or calculate
|
|
877
893
|
- Checkout DOES have a `total` field, but Cart does not
|
|
878
|
-
- `getCartTotals()` works with all carts
|
|
894
|
+
- `getCartTotals()` works with all carts; guests now use server-side session carts with full pricing fields.
|
|
879
895
|
|
|
880
896
|
### 15. SearchSuggestions - Products Have `price`, Not `basePrice`
|
|
881
897
|
|
|
@@ -964,9 +980,9 @@ const totals = getCartTotals(cart); // { subtotal, discount, shipping, total }
|
|
|
964
980
|
|
|
965
981
|
### Detecting a Silent Session Cart Reset
|
|
966
982
|
|
|
967
|
-
If a guest's stored session cart can no longer be resolved as-is
|
|
968
|
-
fails, or the cart was found but is no longer `ACTIVE
|
|
969
|
-
on it already completed)
|
|
983
|
+
If a guest's stored session cart can no longer be resolved as-is (the fetch
|
|
984
|
+
fails, or the cart was found but is no longer `ACTIVE`, e.g. a prior checkout
|
|
985
|
+
on it already completed), `smartAddToCart()` / `smartGetCart()` /
|
|
970
986
|
`smartUpdateCartItem()` transparently start a fresh empty cart so the call
|
|
971
987
|
still succeeds. Pass `onCartReset` to the constructor to find out when this
|
|
972
988
|
happens, so you can tell the shopper their cart expired instead of them just
|
|
@@ -982,7 +998,7 @@ const client = new BrainerceClient({
|
|
|
982
998
|
});
|
|
983
999
|
```
|
|
984
1000
|
|
|
985
|
-
### On Login
|
|
1001
|
+
### On Login: Merge Guest Cart
|
|
986
1002
|
|
|
987
1003
|
```typescript
|
|
988
1004
|
// After setting customer token
|
|
@@ -1026,8 +1042,9 @@ await client.setCheckoutCustomer(checkout.id, {
|
|
|
1026
1042
|
lastName: 'Doe',
|
|
1027
1043
|
});
|
|
1028
1044
|
|
|
1029
|
-
// 5. Set shipping address
|
|
1045
|
+
// 5. Set shipping address (email is required here too, even though step 4 sent it)
|
|
1030
1046
|
await client.setShippingAddress(checkout.id, {
|
|
1047
|
+
email: 'customer@example.com',
|
|
1031
1048
|
firstName: 'John',
|
|
1032
1049
|
lastName: 'Doe',
|
|
1033
1050
|
line1: '123 Main St',
|
|
@@ -1053,17 +1070,17 @@ console.log('Order created:', orderId);
|
|
|
1053
1070
|
|
|
1054
1071
|
Turn the shipping address's `line1` input into a typeahead instead of free
|
|
1055
1072
|
text. Suggestions come from Google Places; each resolved address is flagged
|
|
1056
|
-
`inZone` against the store's configured shipping zones
|
|
1057
|
-
warning banner
|
|
1073
|
+
`inZone` against the store's configured shipping zones, a soft signal for a
|
|
1074
|
+
warning banner and never a hard block.
|
|
1058
1075
|
|
|
1059
1076
|
Suggestions are limited to deliverable address types (street addresses, routes,
|
|
1060
1077
|
buildings, sub-premises). Businesses, stations and other establishments are
|
|
1061
|
-
never returned
|
|
1078
|
+
never returned, because a courier cannot deliver to one. A shopper who types only a
|
|
1062
1079
|
landmark name gets an empty list and has to type the street.
|
|
1063
1080
|
|
|
1064
1081
|
`inZone` resolves a zone's currency-region restriction the same way the checkout
|
|
1065
|
-
does
|
|
1066
|
-
default region
|
|
1082
|
+
does (destination country first, then the `regionId` you pass, then the store's
|
|
1083
|
+
default region), so a `true` here is not contradicted by the rates you fetch
|
|
1067
1084
|
afterwards.
|
|
1068
1085
|
|
|
1069
1086
|
```typescript
|
|
@@ -1116,6 +1133,7 @@ const region = validRegions.some((r) => r.code === address.region) ? address.reg
|
|
|
1116
1133
|
// yourself. Use `address.lat`/`address.lng` for your own UI — a map pin, a
|
|
1117
1134
|
// distance readout — and nothing else.
|
|
1118
1135
|
await client.setShippingAddress(checkout.id, {
|
|
1136
|
+
email: 'customer@example.com', // required. The resolved address carries no email.
|
|
1119
1137
|
firstName: 'John',
|
|
1120
1138
|
lastName: 'Doe',
|
|
1121
1139
|
...address,
|
|
@@ -1157,7 +1175,7 @@ await client.smartUpdateCartItem('prod_123', 5);
|
|
|
1157
1175
|
await client.smartRemoveFromCart('prod_123');
|
|
1158
1176
|
```
|
|
1159
1177
|
|
|
1160
|
-
### After Login
|
|
1178
|
+
### After Login: Sync Cart
|
|
1161
1179
|
|
|
1162
1180
|
```typescript
|
|
1163
1181
|
client.setCustomerToken(token);
|
|
@@ -1165,14 +1183,14 @@ const mergedCart = await client.syncCartOnLogin();
|
|
|
1165
1183
|
// Guest session cart items are merged into the customer's server cart
|
|
1166
1184
|
```
|
|
1167
1185
|
|
|
1168
|
-
### After Checkout
|
|
1186
|
+
### After Checkout: Clear Cart
|
|
1169
1187
|
|
|
1170
1188
|
```typescript
|
|
1171
1189
|
client.onCheckoutComplete();
|
|
1172
1190
|
// Clears session cart reference so next visit starts fresh
|
|
1173
1191
|
```
|
|
1174
1192
|
|
|
1175
|
-
### After Logout
|
|
1193
|
+
### After Logout: Preserve Guest Cart
|
|
1176
1194
|
|
|
1177
1195
|
```typescript
|
|
1178
1196
|
client.clearCustomerToken();
|
|
@@ -1373,7 +1391,7 @@ interface CategoryNode {
|
|
|
1373
1391
|
|
|
1374
1392
|
#### Get Category by Slug (Category Page)
|
|
1375
1393
|
|
|
1376
|
-
Category (collection) pages are the highest-leverage organic-SEO surface
|
|
1394
|
+
Category (collection) pages are the highest-leverage organic-SEO surface, because they rank for broad "research intent" queries that individual product pages never do. `getCategoryBySlug` returns the landing-page payload; fetch the products themselves with `getProducts({ categories: [category.id] })`.
|
|
1377
1395
|
|
|
1378
1396
|
```typescript
|
|
1379
1397
|
// app/category/[slug]/page.tsx
|
|
@@ -1550,7 +1568,7 @@ function ProductFilters() {
|
|
|
1550
1568
|
|
|
1551
1569
|
**Key points for AI builders:**
|
|
1552
1570
|
|
|
1553
|
-
- `getCategories()` returns a **tree
|
|
1571
|
+
- `getCategories()` returns a **tree**; don't flatten it! Use `children` to build nested UI.
|
|
1554
1572
|
- Selecting a parent category automatically includes all descendants (backend handles this).
|
|
1555
1573
|
- Use `position: relative` on the chip wrapper and `position: absolute` on the dropdown for proper overlay positioning.
|
|
1556
1574
|
- Use `paddingInlineStart` (not `paddingLeft`) for RTL support.
|
|
@@ -1692,7 +1710,7 @@ function SearchInput() {
|
|
|
1692
1710
|
|
|
1693
1711
|
#### Product Type Definition
|
|
1694
1712
|
|
|
1695
|
-
> **The shipped `.d.ts` is the authority.** These are abridged for reading
|
|
1713
|
+
> **The shipped `.d.ts` is the authority.** These are abridged for reading, so
|
|
1696
1714
|
> import the real types (`import type { Product, ProductVariant } from 'brainerce'`)
|
|
1697
1715
|
> rather than retyping them. See the Critical Rule: _never write your own copies
|
|
1698
1716
|
> of SDK types._
|
|
@@ -1791,14 +1809,14 @@ interface InventoryInfo {
|
|
|
1791
1809
|
|
|
1792
1810
|
> **Variant prices are strings.** `variant.price` and `variant.salePrice` are
|
|
1793
1811
|
> `string | null`, exactly like `product.basePrice`. `variant.price > 100` compares
|
|
1794
|
-
> lexicographically and silently returns the wrong answer
|
|
1812
|
+
> lexicographically and silently returns the wrong answer, so always `parseFloat()`
|
|
1795
1813
|
> first, or use `getVariantPrice(variant)` / `formatVariantPrice(variant)`.
|
|
1796
1814
|
|
|
1797
1815
|
#### Product Metafields (Custom Fields)
|
|
1798
1816
|
|
|
1799
1817
|
Products can have custom fields (metafields) defined by the store owner, such as "Material", "Care Instructions", or "Warranty".
|
|
1800
1818
|
|
|
1801
|
-
**Important:** Each metafield has a `type` field. When rendering, you **must** check `field.type` and render accordingly
|
|
1819
|
+
**Important:** Each metafield has a `type` field. When rendering, you **must** check `field.type` and render accordingly, not just display `field.value` as text for all types.
|
|
1802
1820
|
|
|
1803
1821
|
| Type | Rendering |
|
|
1804
1822
|
| ----------------------------------------------------------- | ------------------------------------------------------- |
|
|
@@ -1870,7 +1888,7 @@ definitions.forEach((def) => {
|
|
|
1870
1888
|
**Faceted filtering with product counts.** Definitions the merchant marked
|
|
1871
1889
|
`filterable: true` (types `SELECT` / `MULTI_SELECT` / `BOOLEAN`) can be
|
|
1872
1890
|
rendered as storefront facets. `getMetafieldFilters()` returns each of them
|
|
1873
|
-
with per-value counts of distinct active products
|
|
1891
|
+
with per-value counts of distinct active products, so you can show
|
|
1874
1892
|
"Color: red (12) / blue (3)" without one `getProducts` call per value:
|
|
1875
1893
|
|
|
1876
1894
|
```typescript
|
|
@@ -1942,9 +1960,9 @@ await client.addToCart(cartId, {
|
|
|
1942
1960
|
});
|
|
1943
1961
|
```
|
|
1944
1962
|
|
|
1945
|
-
**Display customizations in cart/checkout
|
|
1963
|
+
**Display customizations in cart/checkout, with no extra API call needed:**
|
|
1946
1964
|
|
|
1947
|
-
`CartItem` and `CheckoutLineItem` both include a `customizations` object with resolved labels. Use it directly
|
|
1965
|
+
`CartItem` and `CheckoutLineItem` both include a `customizations` object with resolved labels. Use it directly; no need to call `getProduct()` per item.
|
|
1948
1966
|
|
|
1949
1967
|
```typescript
|
|
1950
1968
|
// Works for CartItem, CheckoutLineItem, and OrderItem — same shape
|
|
@@ -1985,7 +2003,7 @@ await client.addToCart(cartId, {
|
|
|
1985
2003
|
| GALLERY | `string[]` (URLs) | Multi-file upload |
|
|
1986
2004
|
| DIMENSION/WEIGHT | `{ value, unit }` | Value + unit inputs |
|
|
1987
2005
|
|
|
1988
|
-
**Assigning fields to a product is dashboard-only
|
|
2006
|
+
**Assigning fields to a product is dashboard-only; there is no SDK path to it.**
|
|
1989
2007
|
`client.setProductCustomizationFields()` and `client.getProductCustomizationFields()`
|
|
1990
2008
|
target `/api/v1/metafield-definitions/products/:productId/customization-fields`, which
|
|
1991
2009
|
the public API does not expose; both return **404**. Those routes exist only on the
|
|
@@ -1994,8 +2012,8 @@ behind Clerk auth. Choose which customer-input definitions apply to a product in
|
|
|
1994
2012
|
dashboard.
|
|
1995
2013
|
|
|
1996
2014
|
> **Two different things share the name `getProductCustomizationFields`.** The
|
|
1997
|
-
> **exported helper** used above
|
|
1998
|
-
>
|
|
2015
|
+
> **exported helper** used above, `import { getProductCustomizationFields } from 'brainerce'`,
|
|
2016
|
+
> is a pure function that reads the definitions off a product you already fetched. It
|
|
1999
2017
|
> works in every mode and is the one you want. The **client method** of the same name,
|
|
2000
2018
|
> which writes the assignment, is the one that 404s. Reading is fully covered without it:
|
|
2001
2019
|
> `product.customizationFields` is already on every product response.
|
|
@@ -2109,6 +2127,161 @@ function ProductDescription({ product }: { product: Product }) {
|
|
|
2109
2127
|
|
|
2110
2128
|
---
|
|
2111
2129
|
|
|
2130
|
+
### Product Reviews
|
|
2131
|
+
|
|
2132
|
+
Customer reviews on the product page, with optional customer photos. Reviews
|
|
2133
|
+
publish immediately, there is no pending state, and the merchant hides
|
|
2134
|
+
individual reviews or photos afterwards (see [Review Moderation](#review-moderation)
|
|
2135
|
+
in the Admin API Reference).
|
|
2136
|
+
|
|
2137
|
+
Reading reviews is public: `listProductReviews` and the `avgRating` /
|
|
2138
|
+
`reviewCount` summary need no login. Writing is not. Only customers who bought
|
|
2139
|
+
the product may review it, so `getMyProductReview`, `uploadReviewPhoto`,
|
|
2140
|
+
`submitProductReview`, `updateMyProductReview` and `deleteMyProductReview` all
|
|
2141
|
+
need a customer token set with `setCustomerToken(...)` and return 401 without
|
|
2142
|
+
one. Author name and email come from the customer profile server-side; never
|
|
2143
|
+
send them.
|
|
2144
|
+
|
|
2145
|
+
The product itself carries `avgRating` and `reviewCount` for the star summary
|
|
2146
|
+
and the JSON-LD `aggregateRating`, so a rating badge on a product card costs no
|
|
2147
|
+
extra request.
|
|
2148
|
+
|
|
2149
|
+
#### List Reviews
|
|
2150
|
+
|
|
2151
|
+
```typescript
|
|
2152
|
+
const { data, meta } = await client.listProductReviews('prod_123', {
|
|
2153
|
+
page: 1,
|
|
2154
|
+
limit: 20,
|
|
2155
|
+
sort: 'photos_first', // 'photos_first' (default) | 'newest'
|
|
2156
|
+
});
|
|
2157
|
+
|
|
2158
|
+
data.forEach((review) => {
|
|
2159
|
+
console.log(review.rating, review.body, review.verifiedPurchase, review.authorName);
|
|
2160
|
+
// review.images is ALWAYS an array, already filtered to the photos shoppers
|
|
2161
|
+
// may see. Set width/height on the <img> so the gallery reserves space
|
|
2162
|
+
// instead of shifting as the photos load.
|
|
2163
|
+
review.images.forEach((img) => console.log(img.thumbnailUrl ?? img.url, img.width, img.height));
|
|
2164
|
+
});
|
|
2165
|
+
|
|
2166
|
+
console.log(meta.total); // total visible reviews
|
|
2167
|
+
```
|
|
2168
|
+
|
|
2169
|
+
`sort` defaults to `photos_first`: reviews carrying photos lead, newest first
|
|
2170
|
+
within each group. Pass `sort: 'newest'` for plain chronological order. On a
|
|
2171
|
+
store with no review photos the two orders are identical. Hidden reviews are
|
|
2172
|
+
never returned here.
|
|
2173
|
+
|
|
2174
|
+
No customer token is needed for this call: it is the one review call a
|
|
2175
|
+
signed-out shopper can make.
|
|
2176
|
+
|
|
2177
|
+
#### Read the Customer's Own Review State
|
|
2178
|
+
|
|
2179
|
+
Requires a customer token. Call it before rendering any review UI: it answers
|
|
2180
|
+
all four cases in one request, sign in, not eligible, submit, edit.
|
|
2181
|
+
|
|
2182
|
+
```typescript
|
|
2183
|
+
const { eligible, reason, myReview, photos, myImages } =
|
|
2184
|
+
await client.getMyProductReview('prod_123');
|
|
2185
|
+
|
|
2186
|
+
if (!eligible) {
|
|
2187
|
+
// reason: 'no_eligible_order' | 'reviews_disabled' | 'product_not_found' | null
|
|
2188
|
+
showMessage(reason);
|
|
2189
|
+
} else if (myReview) {
|
|
2190
|
+
renderEditForm(myReview);
|
|
2191
|
+
} else {
|
|
2192
|
+
renderSubmitForm();
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
// The store's live photo policy. Read it instead of hardcoding limits.
|
|
2196
|
+
photos.enabled; // render the file picker only when true
|
|
2197
|
+
photos.maxPerReview; // cap the selection at this
|
|
2198
|
+
photos.maxBytes; // reject oversized files before uploading
|
|
2199
|
+
photos.requiresApproval; // true = tell the customer their photo waits for the merchant
|
|
2200
|
+
|
|
2201
|
+
// The customer's OWN photos, including ones still pending approval, so their
|
|
2202
|
+
// upload looks queued rather than failed. `myReview.images` carries only the
|
|
2203
|
+
// publicly visible subset.
|
|
2204
|
+
myImages.forEach((img) => console.log(img.approvedAt, img.hiddenAt));
|
|
2205
|
+
```
|
|
2206
|
+
|
|
2207
|
+
#### Upload Photos
|
|
2208
|
+
|
|
2209
|
+
Requires a customer token, and the server re-checks the purchase before it
|
|
2210
|
+
stores any bytes. Upload each file first, then pass the returned keys on submit.
|
|
2211
|
+
Keys, not URLs: the server resolves each one against the store's own assets and
|
|
2212
|
+
rejects anything that is not a review photo.
|
|
2213
|
+
|
|
2214
|
+
```typescript
|
|
2215
|
+
const { photos } = await client.getMyProductReview(productId);
|
|
2216
|
+
|
|
2217
|
+
if (photos.enabled) {
|
|
2218
|
+
const uploads = await Promise.all(
|
|
2219
|
+
[...fileInput.files]
|
|
2220
|
+
.slice(0, photos.maxPerReview)
|
|
2221
|
+
.map((file) => client.uploadReviewPhoto(productId, file))
|
|
2222
|
+
);
|
|
2223
|
+
|
|
2224
|
+
// Each upload: { key, url, width, height }. `url` is for a local preview only,
|
|
2225
|
+
// `key` is what you send back.
|
|
2226
|
+
const imageKeys = uploads.map((u) => u.key);
|
|
2227
|
+
}
|
|
2228
|
+
```
|
|
2229
|
+
|
|
2230
|
+
Limits and behaviour:
|
|
2231
|
+
|
|
2232
|
+
- JPEG, PNG, WebP or GIF only. Max 5 MB and 40 megapixels per file. The server
|
|
2233
|
+
checks the real bytes, not the declared MIME type, so a renamed file is rejected.
|
|
2234
|
+
- Throttled to 10 uploads per minute, so a burst returns HTTP 429.
|
|
2235
|
+
- 403 when the store has review photos turned off, or the customer did not buy
|
|
2236
|
+
the product. 400 once the review is already at its photo cap.
|
|
2237
|
+
- EXIF is stripped, so GPS coordinates never reach the storefront, and the
|
|
2238
|
+
orientation tag is applied first, so phone photos stay upright.
|
|
2239
|
+
- A photo uploaded but never attached to a submitted review is reclaimed after 7
|
|
2240
|
+
days.
|
|
2241
|
+
|
|
2242
|
+
#### Submit a Review
|
|
2243
|
+
|
|
2244
|
+
Requires a customer token.
|
|
2245
|
+
|
|
2246
|
+
```typescript
|
|
2247
|
+
const review = await client.submitProductReview('prod_123', {
|
|
2248
|
+
rating: 5, // 1-5
|
|
2249
|
+
body: 'Arrived beautifully wrapped.', // optional
|
|
2250
|
+
imageKeys, // optional, in display order
|
|
2251
|
+
});
|
|
2252
|
+
```
|
|
2253
|
+
|
|
2254
|
+
- 403 `no_eligible_order` when the customer never bought the product. Eligibility
|
|
2255
|
+
is SHIPPED for physical products, PAID for downloadable ones.
|
|
2256
|
+
- 409 when they already reviewed this product. Call `updateMyProductReview`
|
|
2257
|
+
instead, which is why you read `getMyProductReview` first.
|
|
2258
|
+
|
|
2259
|
+
#### Edit or Delete Their Own Review
|
|
2260
|
+
|
|
2261
|
+
Requires a customer token. Both calls act on the caller's own review for that
|
|
2262
|
+
product; there is no review id to pass and no way to touch anyone else's.
|
|
2263
|
+
|
|
2264
|
+
```typescript
|
|
2265
|
+
// Rating, body and photos. authorName and verifiedPurchase are preserved from
|
|
2266
|
+
// the original submit and cannot be changed.
|
|
2267
|
+
await client.updateMyProductReview('prod_123', {
|
|
2268
|
+
rating: 4,
|
|
2269
|
+
body: 'Still good after a month of use.',
|
|
2270
|
+
imageKeys: ['key_a', 'key_b'],
|
|
2271
|
+
});
|
|
2272
|
+
|
|
2273
|
+
// Deleting frees the customer to submit a new review, subject to eligibility.
|
|
2274
|
+
await client.deleteMyProductReview('prod_123');
|
|
2275
|
+
```
|
|
2276
|
+
|
|
2277
|
+
> **`imageKeys` REPLACES the photo set on update.** Pass the keys you want to
|
|
2278
|
+
> keep, including the existing ones. Omitting the field entirely leaves the
|
|
2279
|
+
> current photos untouched; passing `[]` removes them all. The key of an
|
|
2280
|
+
> already-attached photo is `assetKey` on each entry of
|
|
2281
|
+
> `getMyProductReview().myImages`.
|
|
2282
|
+
|
|
2283
|
+
---
|
|
2284
|
+
|
|
2112
2285
|
### Cart Operations (All Users)
|
|
2113
2286
|
|
|
2114
2287
|
The `smart*` methods work for both guests and logged-in users. Guests use server-side session carts; logged-in users use server carts linked to their account.
|
|
@@ -2120,9 +2293,35 @@ await client.smartAddToCart({
|
|
|
2120
2293
|
productId: 'prod_123',
|
|
2121
2294
|
variantId: 'var_456', // Optional: for products with variants
|
|
2122
2295
|
quantity: 2,
|
|
2296
|
+
|
|
2297
|
+
// Optional: buyer customization values (engraving text, uploaded photo URL,
|
|
2298
|
+
// SELECT / MULTI_SELECT picks). Keys are the `key` of each entry in
|
|
2299
|
+
// product.customizationFields. See "Product customization fields" above.
|
|
2300
|
+
metadata: {
|
|
2301
|
+
engraving_text: 'Happy Birthday!',
|
|
2302
|
+
frame_color: 'Gold',
|
|
2303
|
+
},
|
|
2304
|
+
|
|
2305
|
+
// Optional: modifier-group picks (toppings, sauce, build-your-own).
|
|
2306
|
+
selections: [
|
|
2307
|
+
{ modifierGroupId: 'mg_bread', modifierIds: ['m_thick'] },
|
|
2308
|
+
{ modifierGroupId: 'mg_toppings', modifierIds: ['m_olive', 'm_bacon'] },
|
|
2309
|
+
],
|
|
2310
|
+
|
|
2311
|
+
// Optional: nested combo picks, keyed by the PARENT modifierId. Max 3 levels.
|
|
2312
|
+
nestedByModifierId: {
|
|
2313
|
+
m_side_drink: [{ modifierGroupId: 'mg_size', modifierIds: ['m_large'] }],
|
|
2314
|
+
},
|
|
2123
2315
|
});
|
|
2124
2316
|
```
|
|
2125
2317
|
|
|
2318
|
+
> `metadata`, `selections` and `nestedByModifierId` are the same three fields the
|
|
2319
|
+
> low-level `addToCart(cartId, item)` takes, and they behave identically here.
|
|
2320
|
+
> Reach for `smartAddToCart` on a storefront: it resolves the right cart for a
|
|
2321
|
+
> guest or a logged-in shopper, so you never need a `cartId` of your own.
|
|
2322
|
+
> Validation stays server-side, so a bad modifier set still comes back as a
|
|
2323
|
+
> `MODIFIER_VALIDATION_FAILED` envelope on `BrainerceError.details`.
|
|
2324
|
+
|
|
2126
2325
|
#### Get Cart
|
|
2127
2326
|
|
|
2128
2327
|
```typescript
|
|
@@ -2175,7 +2374,7 @@ console.log(updated.couponCode); // "SAVE20"
|
|
|
2175
2374
|
await client.removeCoupon(cart.id);
|
|
2176
2375
|
```
|
|
2177
2376
|
|
|
2178
|
-
**On the checkout page** (checkout session already exists
|
|
2377
|
+
**On the checkout page** (checkout session already exists, preferred):
|
|
2179
2378
|
|
|
2180
2379
|
```typescript
|
|
2181
2380
|
// applyCheckoutCoupon applies to cart AND updates checkout totals atomically
|
|
@@ -2186,7 +2385,7 @@ console.log(checkout.total); // updated total
|
|
|
2186
2385
|
await client.removeCheckoutCoupon(checkoutId);
|
|
2187
2386
|
```
|
|
2188
2387
|
|
|
2189
|
-
> **Important:** if a checkout session already exists, always use `applyCheckoutCoupon(checkoutId, code)
|
|
2388
|
+
> **Important:** if a checkout session already exists, always use `applyCheckoutCoupon(checkoutId, code)`, not `applyCoupon(cartId, code)`. Applying to the cart after checkout is created does not update the checkout total, so payment will charge the original amount.
|
|
2190
2389
|
|
|
2191
2390
|
#### Cart Totals
|
|
2192
2391
|
|
|
@@ -2200,12 +2399,12 @@ const totals = getCartTotals(cart);
|
|
|
2200
2399
|
|
|
2201
2400
|
---
|
|
2202
2401
|
|
|
2203
|
-
### Guest Checkout (Submit Order)
|
|
2402
|
+
### Guest Checkout (Submit Order): no payment collected
|
|
2204
2403
|
|
|
2205
2404
|
> **⛔ Not for stores that take payment.** `submitGuestOrder()` posts the order
|
|
2206
2405
|
> straight to `POST /orders`. It never creates a payment intent, so the order is
|
|
2207
2406
|
> created **unpaid** and no card is ever charged. Use it only where checkout
|
|
2208
|
-
> collects no money
|
|
2407
|
+
> collects no money: cash on delivery, manual invoicing, or a sandbox store.
|
|
2209
2408
|
>
|
|
2210
2409
|
> For every other store use `startGuestCheckout()`, which creates a real checkout
|
|
2211
2410
|
> session from the session cart and hands you a `checkoutId` to run the payment
|
|
@@ -2355,7 +2554,7 @@ const cart = await client.createCart();
|
|
|
2355
2554
|
> **GA4 server-side conversions:** if you called `client.loadGoogleAnalytics('G-XXXXXXX')`
|
|
2356
2555
|
> once at app startup (see [Analytics](#analytics-optional-server-side-ga4-conversions)
|
|
2357
2556
|
> below), the resolved `client_id`/`session_id` are auto-attached to `createCart`,
|
|
2358
|
-
> `addToCart`, `setCheckoutCustomer`, and `setShippingAddress` automatically
|
|
2557
|
+
> `addToCart`, `setCheckoutCustomer`, and `setShippingAddress` automatically, with no
|
|
2359
2558
|
> other code changes needed. Pass `analyticsClientId`/`analyticsSessionId`
|
|
2360
2559
|
> explicitly on any of these calls to override.
|
|
2361
2560
|
|
|
@@ -2490,17 +2689,17 @@ const checkout = await client.createCheckout({
|
|
|
2490
2689
|
|
|
2491
2690
|
> The region is recorded on the checkout and used for payment-provider scoping.
|
|
2492
2691
|
> **FX-at-checkout:** when the region currency differs from the store base and its
|
|
2493
|
-
> provider can settle it (presentment-enabled
|
|
2692
|
+
> provider can settle it (presentment-enabled, Stripe today), the buyer is charged
|
|
2494
2693
|
> in the region currency and the checkout response carries a `presentment` overlay
|
|
2495
2694
|
> (`presentment.total` / `presentment.currency` = what's charged). Otherwise the
|
|
2496
2695
|
> checkout is charged in the store base currency.
|
|
2497
2696
|
|
|
2498
2697
|
#### Region display pricing (`getProducts({ regionId })`)
|
|
2499
2698
|
|
|
2500
|
-
All three product reads accept an optional `regionId
|
|
2699
|
+
All three product reads accept an optional `regionId`: `getProducts`,
|
|
2501
2700
|
`getProduct(id)`, **and** `getProductBySlug(slug)` (the PDP path). When set and the
|
|
2502
2701
|
region's currency differs from the store currency, each product/variant gains
|
|
2503
|
-
additive FX-display fields
|
|
2702
|
+
additive FX-display fields: `displayPrice`, `displaySalePrice`, and
|
|
2504
2703
|
`displayCurrency` (plus `displayPriceMin` / `displayPriceMax` on storefront-mode
|
|
2505
2704
|
list reads). Your `basePrice` / `salePrice` stay in the store currency; the
|
|
2506
2705
|
display fields appear **only** when an FX rate applies, so an omitted/invalid
|
|
@@ -2519,11 +2718,11 @@ await client.getProductBySlug('blue-shirt', { regionId: 'region_eu' });
|
|
|
2519
2718
|
```
|
|
2520
2719
|
|
|
2521
2720
|
> **Display-only, and unlike checkout.** `regionId` on a _product read_ never
|
|
2522
|
-
> affects what is charged
|
|
2721
|
+
> affects what is charged; it only changes what you render. `regionId` on
|
|
2523
2722
|
> `createCheckout()` is different: it CAN charge the region currency (see
|
|
2524
2723
|
> FX-at-checkout above). Do not carry the "display-only" assumption from here into
|
|
2525
2724
|
> the checkout step. Works in
|
|
2526
|
-
> **all three modes
|
|
2725
|
+
> **all three modes**: vibe-coded (`vc_*`/`salesChannelId`), storefront (`storeId`),
|
|
2527
2726
|
> and admin (`apiKey`). The response gains `displayPrice` whenever a daily FX rate
|
|
2528
2727
|
> exists for the store/region currency pair in **either** direction (the overlay
|
|
2529
2728
|
> inverts the stored rate when needed). See [Regions](/docs/concepts/regions).
|
|
@@ -2576,6 +2775,7 @@ const checkout = await client.setCheckoutCustomer(checkoutId, {
|
|
|
2576
2775
|
|
|
2577
2776
|
```typescript
|
|
2578
2777
|
const { checkout, rates } = await client.setShippingAddress(checkoutId, {
|
|
2778
|
+
email: 'customer@example.com', // REQUIRED, on every call
|
|
2579
2779
|
firstName: 'John',
|
|
2580
2780
|
lastName: 'Doe',
|
|
2581
2781
|
line1: '123 Main St',
|
|
@@ -2594,17 +2794,22 @@ const { checkout, rates } = await client.setShippingAddress(checkoutId, {
|
|
|
2594
2794
|
console.log(rates); // ShippingRate[]
|
|
2595
2795
|
```
|
|
2596
2796
|
|
|
2797
|
+
> **`email` is required, including for logged-in customers and including when
|
|
2798
|
+
> you already sent it to `setCheckoutCustomer`.** It is validated before any
|
|
2799
|
+
> service code runs, so the server never fills it in from the customer record;
|
|
2800
|
+
> omit it or send an empty string and the call fails with a 400.
|
|
2801
|
+
|
|
2597
2802
|
> **Always pass `placeId` when you use address autocomplete.** The server
|
|
2598
2803
|
> re-resolves it to the address's exact coordinates, which is how stores that
|
|
2599
2804
|
> draw their delivery areas on a map ("polygon" zones) decide whether they
|
|
2600
2805
|
> cover the shopper. Without it the server falls back to geocoding the typed
|
|
2601
|
-
> address text, which is materially less precise
|
|
2806
|
+
> address text, which is materially less precise: a same-named street in a
|
|
2602
2807
|
> neighbouring city can outrank the right one, quoting the shopper another
|
|
2603
2808
|
> area's rate or no delivery at all. **Clear `placeId` if the shopper edits any
|
|
2604
|
-
> address field after picking
|
|
2809
|
+
> address field after picking**, because its coordinates describe the suggestion, not
|
|
2605
2810
|
> the edited text. There is deliberately no `lat`/`lng` field: zone matching
|
|
2606
2811
|
> decides which rate is charged, so coordinates are never accepted from the
|
|
2607
|
-
> client
|
|
2812
|
+
> client. The server resolves them from `placeId` itself.
|
|
2608
2813
|
>
|
|
2609
2814
|
> The endpoint rejects **any** unknown property with a `400` ("property lat
|
|
2610
2815
|
> should not exist"), which blocks checkout entirely rather than degrading. So
|
|
@@ -2619,7 +2824,7 @@ console.log(rates); // ShippingRate[]
|
|
|
2619
2824
|
> same area, put several rates on one zone rather than several zones.
|
|
2620
2825
|
|
|
2621
2826
|
> **Live carrier rates:** render `rate.speedTier` (`'cheapest' | 'balanced' | 'fastest'`)
|
|
2622
|
-
> and `rate.estimatedDays`, not `rate.name
|
|
2827
|
+
> and `rate.estimatedDays`, not `rate.name`, because `name` is the carrier's own service
|
|
2623
2828
|
> identifier and means nothing to a shopper. Rates arrive already narrowed to at most
|
|
2624
2829
|
> three. Manual zone rates carry no `speedTier`; show their `name` as the merchant wrote
|
|
2625
2830
|
> it. Full snippet under [Checkout Type Definition](#checkout-type-definition).
|
|
@@ -2627,7 +2832,7 @@ console.log(rates); // ShippingRate[]
|
|
|
2627
2832
|
> **Order notes:** every checkout page should render an optional **"Order
|
|
2628
2833
|
> notes"** textarea by default. Send its value via `notes` on either
|
|
2629
2834
|
> `setCheckoutCustomer` or `setShippingAddress` (whichever call your flow
|
|
2630
|
-
> makes last)
|
|
2835
|
+
> makes last). Max 2000 chars, and an empty string clears a previously-set note.
|
|
2631
2836
|
> The note is copied onto the order at completion (merchant sees it in the
|
|
2632
2837
|
> dashboard, it's included in the confirmation email) and echoed back
|
|
2633
2838
|
> read-only as `Order.notes` on buyer order responses.
|
|
@@ -2687,10 +2892,10 @@ const updatedCheckout = await client.setCheckoutCustomFields(checkoutId, {
|
|
|
2687
2892
|
|
|
2688
2893
|
A `DATE`/`DATETIME` field's `dateAvailability` (blocked weekdays, blocked specific
|
|
2689
2894
|
dates, min/max date range, the relative bounds `leadTimeMinutes` / `cutoffTime` /
|
|
2690
|
-
`maxDaysAhead`,
|
|
2895
|
+
`maxDaysAhead`, plus (for `DATETIME`) business hours and time
|
|
2691
2896
|
slots) is a merchant-configured restriction on which values the customer may
|
|
2692
2897
|
pick. Use `computeAvailableSlots()` / `getBusinessHoursForDate()` /
|
|
2693
|
-
`isDateValueAllowed()` to drive your own date-picker/slot-picker UI
|
|
2898
|
+
`isDateValueAllowed()` to drive your own date-picker/slot-picker UI. The SDK
|
|
2694
2899
|
ships no calendar component, only the math (evaluated in the **store's**
|
|
2695
2900
|
timezone, never the browser's):
|
|
2696
2901
|
|
|
@@ -2735,10 +2940,10 @@ if (slots.length) {
|
|
|
2735
2940
|
```
|
|
2736
2941
|
|
|
2737
2942
|
**Submitting the value.** `DATE` is `"YYYY-MM-DD"`. `DATETIME` is one ISO-8601
|
|
2738
|
-
value
|
|
2943
|
+
value: `"2026-08-15T09:30:00+03:00"`, or `"2026-08-15T09:30"` to mean the
|
|
2739
2944
|
store's own timezone (the safest choice: a buyer travelling abroad would
|
|
2740
2945
|
otherwise book their local hour). Fractional seconds are optional and may carry
|
|
2741
|
-
1
|
|
2946
|
+
1 to 9 digits, so `Instant.toString()` / `datetime.isoformat()` output from a
|
|
2742
2947
|
non-JS backend is accepted as-is. **Never build it by concatenating a slot
|
|
2743
2948
|
label**: `` `${date}T13:00-14:00` `` is rejected with HTTP 400 because
|
|
2744
2949
|
`-14:00` parses as a UTC offset, not a time range.
|
|
@@ -2761,7 +2966,7 @@ concrete dates they currently mean, which is what to show a shopper who asked
|
|
|
2761
2966
|
for something too soon.
|
|
2762
2967
|
|
|
2763
2968
|
The backend independently re-validates every submitted value against the same
|
|
2764
|
-
constraints at write time
|
|
2969
|
+
constraints at write time. This is a client-side UX aid, not the source of
|
|
2765
2970
|
enforcement.
|
|
2766
2971
|
|
|
2767
2972
|
**Pricing types:**
|
|
@@ -2813,8 +3018,8 @@ interface ShippingRate {
|
|
|
2813
3018
|
```
|
|
2814
3019
|
|
|
2815
3020
|
**Render `speedTier` and `estimatedDays`, not `name`, for live carrier rates.**
|
|
2816
|
-
`name` carries the carrier's own service identifier
|
|
2817
|
-
`USAExportPBA USAExportStandard`
|
|
3021
|
+
`name` carries the carrier's own service identifier (`USPS PriorityMailInternational`,
|
|
3022
|
+
`USAExportPBA USAExportStandard`), which answers a question no shopper asked. They are
|
|
2818
3023
|
choosing between _how fast_ and _how much_. Label the tiers in your own words and locale:
|
|
2819
3024
|
|
|
2820
3025
|
```typescript
|
|
@@ -2842,6 +3047,7 @@ The shipping flow involves setting an address and then selecting from available
|
|
|
2842
3047
|
```typescript
|
|
2843
3048
|
// Step 1: Set shipping address - this returns available rates
|
|
2844
3049
|
const { checkout, rates } = await client.setShippingAddress(checkoutId, {
|
|
3050
|
+
email: 'customer@example.com', // required
|
|
2845
3051
|
firstName: 'John',
|
|
2846
3052
|
lastName: 'Doe',
|
|
2847
3053
|
line1: '123 Main St',
|
|
@@ -2963,12 +3169,12 @@ const checkoutId = checkout.id;
|
|
|
2963
3169
|
Use this method to get ALL enabled payment providers and build dynamic UI.
|
|
2964
3170
|
|
|
2965
3171
|
**Primary vs. additive methods (Shopify-parity).** Each provider carries a
|
|
2966
|
-
`methodType`. A `CREDIT_CARD` provider is the _primary_ card processor
|
|
3172
|
+
`methodType`. A `CREDIT_CARD` provider is the _primary_ card processor, the
|
|
2967
3173
|
single method that settles the order (`defaultProvider`, `isAdditive: false`,
|
|
2968
3174
|
`presentation: 'card_form'`). Everything else is _additive_ (`isAdditive: true`),
|
|
2969
3175
|
e.g. PayPal is a `'WALLET'` with `presentation: 'express_button'`. Render additive
|
|
2970
|
-
methods as accelerated-checkout **express buttons above the card form
|
|
2971
|
-
_alongside_ the primary
|
|
3176
|
+
methods as accelerated-checkout **express buttons above the card form**, where they sit
|
|
3177
|
+
_alongside_ the primary and never replace it. (Exception: a wallet-only store has no
|
|
2972
3178
|
card processor, so its wallet becomes the `defaultProvider` and stands alone.)
|
|
2973
3179
|
|
|
2974
3180
|
```typescript
|
|
@@ -3031,7 +3237,7 @@ if (paypalProvider) {
|
|
|
3031
3237
|
}
|
|
3032
3238
|
```
|
|
3033
3239
|
|
|
3034
|
-
#### Get Payment Configuration (Single Provider)
|
|
3240
|
+
#### Get Payment Configuration (Single Provider): DEPRECATED
|
|
3035
3241
|
|
|
3036
3242
|
> **`getPaymentConfig()` is `@deprecated`.** It only ever describes one provider, so
|
|
3037
3243
|
> a store with an additive express method (PayPal, a wallet) renders wrong. Use
|
|
@@ -3080,13 +3286,13 @@ both are omitted from most copy-paste snippets. `clientSdk.renderType` is one of
|
|
|
3080
3286
|
|
|
3081
3287
|
| `renderType` | What `clientSecret` holds | What to do |
|
|
3082
3288
|
| -------------- | ------------------------- | ------------------------------------------------------------------------------------------------ |
|
|
3083
|
-
| `'sandbox'` | (unused) | No payment UI
|
|
3289
|
+
| `'sandbox'` | (unused) | No payment UI; complete the checkout directly |
|
|
3084
3290
|
| `'sdk-widget'` | Client secret / auth code | Load `clientSdk.scriptUrl`, init with `clientSdk.initConfig`, mount into `clientSdk.containerId` |
|
|
3085
3291
|
| `'iframe'` | **A URL** | Load it in an iframe (inline if its path contains `/embed/`, else in a modal) |
|
|
3086
3292
|
| `'redirect'` | **A URL** | Navigate the top-level window to it; on return call `confirmSdkPayment()` |
|
|
3087
3293
|
|
|
3088
|
-
Branch on `clientSdk?.renderType`. **Never** branch on "does `clientSdk` exist"
|
|
3089
|
-
every provider returns one, sandbox included
|
|
3294
|
+
Branch on `clientSdk?.renderType`. **Never** branch on "does `clientSdk` exist":
|
|
3295
|
+
every provider returns one, sandbox included. And never hard-code by provider name.
|
|
3090
3296
|
Only `provider === 'stripe'` has a `clientSdk.initConfig.publishableKey`.
|
|
3091
3297
|
|
|
3092
3298
|
#### Confirm an SDK / redirect payment
|
|
@@ -3102,14 +3308,14 @@ await client.confirmSdkPayment(checkoutId, { transactionId: 'txn_123' });
|
|
|
3102
3308
|
|
|
3103
3309
|
Call it in two places:
|
|
3104
3310
|
|
|
3105
|
-
- **In an in-page SDK's success callback** (`renderType: 'sdk-widget'`)
|
|
3311
|
+
- **In an in-page SDK's success callback** (`renderType: 'sdk-widget'`), where it tells
|
|
3106
3312
|
the backend the payment succeeded, which triggers order creation.
|
|
3107
|
-
- **On the return page from a `renderType: 'redirect'` provider
|
|
3313
|
+
- **On the return page from a `renderType: 'redirect'` provider**, because redirect
|
|
3108
3314
|
providers don't capture until the server confirms, so this is what makes the
|
|
3109
3315
|
backend verify with the provider and capture.
|
|
3110
3316
|
|
|
3111
3317
|
It is **idempotent** (safe if a webhook already captured) and safe to skip on
|
|
3112
|
-
failure
|
|
3318
|
+
failure, since `getPaymentStatus()` / `waitForOrder()` re-verify server-side. Wrap it in
|
|
3113
3319
|
`try/catch` and carry on:
|
|
3114
3320
|
|
|
3115
3321
|
```typescript
|
|
@@ -3121,13 +3327,13 @@ try {
|
|
|
3121
3327
|
const result = await client.waitForOrder(checkoutId);
|
|
3122
3328
|
```
|
|
3123
3329
|
|
|
3124
|
-
Do **not** call it on your `cancelUrl
|
|
3330
|
+
Do **not** call it on your `cancelUrl`. The buyer abandoned; just let them retry.
|
|
3125
3331
|
|
|
3126
3332
|
`confirmGrowPayment()` is a deprecated wrapper around this method; call
|
|
3127
3333
|
`confirmSdkPayment()` directly.
|
|
3128
3334
|
|
|
3129
3335
|
> `confirmSdkPayment()`, `getPaymentStatus()`, `createPaymentIntent()`,
|
|
3130
|
-
> `getPaymentProviders()` and `waitForOrder()` are **sales-channel mode only
|
|
3336
|
+
> `getPaymentProviders()` and `waitForOrder()` are **sales-channel mode only**;
|
|
3131
3337
|
> they throw `BrainerceError` 400 on a `storeId` or `apiKey` client.
|
|
3132
3338
|
|
|
3133
3339
|
**Routing to a specific provider (`providerId`).** With `getPaymentProviders()` you
|
|
@@ -3400,11 +3606,11 @@ function PaymentForm({ checkoutId }: { checkoutId: string }) {
|
|
|
3400
3606
|
|
|
3401
3607
|
#### Complete Order After Payment: `completeGuestCheckout()` (legacy untracked flow only)
|
|
3402
3608
|
|
|
3403
|
-
> **Context:** This section describes the **legacy untracked guest checkout** flow where the client must explicitly create the order. In the modern tracked flow (`startGuestCheckout()` → webhook creates the order), you use `handlePaymentSuccess()` + `waitForOrder()` instead (see [Business Flows
|
|
3609
|
+
> **Context:** This section describes the **legacy untracked guest checkout** flow where the client must explicitly create the order. In the modern tracked flow (`startGuestCheckout()` → webhook creates the order), you use `handlePaymentSuccess()` + `waitForOrder()` instead (see [Business Flows: Checkout](#checkout-flow) above).
|
|
3404
3610
|
|
|
3405
3611
|
**CRITICAL (untracked flow only):** After payment succeeds, you MUST call `completeGuestCheckout()` to create the order on the server.
|
|
3406
3612
|
|
|
3407
|
-
> **WARNING (untracked flow only):** Do NOT use `handlePaymentSuccess()` here
|
|
3613
|
+
> **WARNING (untracked flow only):** Do NOT use `handlePaymentSuccess()` here; it only clears cart state locally and does NOT create the order on the server. The tracked flow uses `handlePaymentSuccess` + `waitForOrder`; the untracked flow uses `completeGuestCheckout` directly.
|
|
3408
3614
|
|
|
3409
3615
|
```typescript
|
|
3410
3616
|
// On your /checkout/success page:
|
|
@@ -3492,7 +3698,7 @@ if (intent.clientSdk?.renderType === 'sandbox') {
|
|
|
3492
3698
|
|
|
3493
3699
|
#### Register Customer
|
|
3494
3700
|
|
|
3495
|
-
> **Password policy
|
|
3701
|
+
> **Password policy, enforced on `registerCustomer()` AND `resetPassword()`.**
|
|
3496
3702
|
> At least **8 characters**, with at least one **lowercase** letter, one
|
|
3497
3703
|
> **uppercase** letter, one **digit**, and one **special character**
|
|
3498
3704
|
> (`/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^a-zA-Z0-9]).{8,}$/`). A password that
|
|
@@ -3501,7 +3707,7 @@ if (intent.clientSdk?.renderType === 'sandbox') {
|
|
|
3501
3707
|
>
|
|
3502
3708
|
> `securepassword123` fails (no uppercase, no special). `Password123` fails (no
|
|
3503
3709
|
> special). `SecurePass123!` passes. Mirror the full rule in your own client-side
|
|
3504
|
-
> validation and in the field's helper text
|
|
3710
|
+
> validation and in the field's helper text. A form that only says "min 8
|
|
3505
3711
|
> characters" produces a 400 the shopper cannot explain, and render the server's
|
|
3506
3712
|
> message verbatim when one comes back.
|
|
3507
3713
|
|
|
@@ -3550,7 +3756,7 @@ localStorage.removeItem('returnUrl');
|
|
|
3550
3756
|
window.location.href = returnUrl;
|
|
3551
3757
|
```
|
|
3552
3758
|
|
|
3553
|
-
> **`setCustomerToken()` is a plain field setter
|
|
3759
|
+
> **`setCustomerToken()` is a plain field setter; it does not touch the cart.**
|
|
3554
3760
|
> Always follow it with `await client.syncCartOnLogin()`. Skip it and the
|
|
3555
3761
|
> shopper's guest cart is never attached to their account, which quietly breaks
|
|
3556
3762
|
> every identity-keyed feature: first-order discounts, per-customer coupon caps,
|
|
@@ -3689,8 +3895,8 @@ await client.reportSocialShare('instagram');
|
|
|
3689
3895
|
#### Referrals & Birthday Gifts
|
|
3690
3896
|
|
|
3691
3897
|
When the store enables referrals, `getLoyaltyStatus()` also returns the
|
|
3692
|
-
member's share code (`referralCode`) and
|
|
3693
|
-
referral link
|
|
3898
|
+
member's share code (`referralCode`) and, for customers who signed up via a
|
|
3899
|
+
referral link, their still-unused welcome coupon (`referralWelcomeCoupon`).
|
|
3694
3900
|
|
|
3695
3901
|
```typescript
|
|
3696
3902
|
// 1. The referrer shares a link you build around their code.
|
|
@@ -3724,7 +3930,7 @@ signup.
|
|
|
3724
3930
|
|
|
3725
3931
|
#### Paid Loyalty Membership
|
|
3726
3932
|
|
|
3727
|
-
Stores can offer a paid "premium membership" inside the loyalty program
|
|
3933
|
+
Stores can offer a paid "premium membership" inside the loyalty program: a
|
|
3728
3934
|
recurring charge (default every 30 days) that grants a points multiplier and
|
|
3729
3935
|
other perks. Storefront or vibe-coded mode, requires `customerToken`. The
|
|
3730
3936
|
customer must first have a saved card (vault one by checking out with
|
|
@@ -3762,7 +3968,7 @@ const cancelled = await client.cancelMembership();
|
|
|
3762
3968
|
|
|
3763
3969
|
#### Embeddable Loyalty Widget
|
|
3764
3970
|
|
|
3765
|
-
Drop the loyalty program into ANY website
|
|
3971
|
+
Drop the loyalty program into ANY website, not just your SDK-connected
|
|
3766
3972
|
storefront. Mint a short-lived widget session (never exposes the real
|
|
3767
3973
|
`customerToken` to the embedding page) and point an `<iframe>` at it. The
|
|
3768
3974
|
merchant enables this per-domain in the dashboard (Loyalty → Settings →
|
|
@@ -3776,7 +3982,7 @@ const { embedUrl } = await client.getLoyaltyWidgetSession();
|
|
|
3776
3982
|
// <iframe src={embedUrl} width="360" height="420" style={{ border: 0 }} />
|
|
3777
3983
|
```
|
|
3778
3984
|
|
|
3779
|
-
`embedUrl` expires in ~15 minutes
|
|
3985
|
+
`embedUrl` expires in ~15 minutes, so re-call `getLoyaltyWidgetSession()` before
|
|
3780
3986
|
that (e.g. on page load) rather than caching it long-term. Storefront or
|
|
3781
3987
|
vibe-coded mode, requires `customerToken`.
|
|
3782
3988
|
|
|
@@ -3983,7 +4189,7 @@ window.location.href = authorizationUrl;
|
|
|
3983
4189
|
|
|
3984
4190
|
The backend handles the OAuth code exchange automatically and redirects to your callback page with URL params. You do **not** need to call `handleOAuthCallback()`.
|
|
3985
4191
|
|
|
3986
|
-
Both outcomes land here
|
|
4192
|
+
Both outcomes land here: success **and** failure. Read `auth_code` and exchange it for the JWT; never read the token from the URL.
|
|
3987
4193
|
|
|
3988
4194
|
```typescript
|
|
3989
4195
|
// app/auth/callback/page.tsx
|
|
@@ -4307,9 +4513,9 @@ console.log(store.language); // 'en', 'he', etc.
|
|
|
4307
4513
|
|
|
4308
4514
|
### Traffic Analytics (built-in, no GA4 needed)
|
|
4309
4515
|
|
|
4310
|
-
Brainerce has a **native cookieless analytics pipeline**
|
|
4516
|
+
Brainerce has a **native cookieless analytics pipeline** covering visits, visitors, countries, sources, devices and the conversion funnel, visible in the merchant dashboard under **Dashboard → Traffic**. You don't need GA4, Meta Pixel, or any third-party script.
|
|
4311
4517
|
|
|
4312
|
-
**Option A
|
|
4518
|
+
**Option A: Script tag (recommended, zero JS):**
|
|
4313
4519
|
|
|
4314
4520
|
Add one line to your root layout `<head>`:
|
|
4315
4521
|
|
|
@@ -4323,7 +4529,7 @@ Add one line to your root layout `<head>`:
|
|
|
4323
4529
|
|
|
4324
4530
|
The pixel auto-tracks pageviews on load and on every SPA route change, de-dupes consecutive identical paths, and measures active dwell time. Scaffolds from `create-brainerce-store ≥ 1.50` include it automatically.
|
|
4325
4531
|
|
|
4326
|
-
**Option B
|
|
4532
|
+
**Option B: SDK method (JS import, same endpoint):**
|
|
4327
4533
|
|
|
4328
4534
|
```typescript
|
|
4329
4535
|
// Basic pageview — call this on route changes if you're not using t.js
|
|
@@ -4344,9 +4550,9 @@ client.trackEvent({
|
|
|
4344
4550
|
client.trackEvent({ eventType: 'engagement', path: '/products/shoes', engagedMs: 12000 });
|
|
4345
4551
|
```
|
|
4346
4552
|
|
|
4347
|
-
> `trackEvent()` is fire-and-forget
|
|
4553
|
+
> `trackEvent()` is fire-and-forget; errors are silently swallowed so a failed beacon never breaks your storefront. Available in `salesChannelId` and `storeId` modes.
|
|
4348
4554
|
|
|
4349
|
-
> **Analytics always goes directly to the Brainerce API** (`keepalive` beacon), even when you route the rest of the SDK through a same-origin BFF proxy (`proxyMode` / `baseUrl: '/api/store'`). This is deliberate: a beacon relayed through your server would arrive from the server's datacenter IP and mis-geolocate **every** visitor to that region (e.g. a Vercel US function → all visitors show as US). When `proxyMode` is on
|
|
4555
|
+
> **Analytics always goes directly to the Brainerce API** (`keepalive` beacon), even when you route the rest of the SDK through a same-origin BFF proxy (`proxyMode` / `baseUrl: '/api/store'`). This is deliberate: a beacon relayed through your server would arrive from the server's datacenter IP and mis-geolocate **every** visitor to that region (e.g. a Vercel US function → all visitors show as US). When `proxyMode` is on, or `baseUrl` shares the page origin, the SDK auto-targets `https://api.brainerce.com` for beacons; override with `analyticsBaseUrl` only if your API is self-hosted.
|
|
4350
4556
|
|
|
4351
4557
|
**Privacy:** cookieless, no PII stored. The visitor IP is resolved to a country server-side and then discarded. No cookie-consent banner required under GDPR/ePrivacy.
|
|
4352
4558
|
|
|
@@ -4354,7 +4560,7 @@ client.trackEvent({ eventType: 'engagement', path: '/products/shoes', engagedMs:
|
|
|
4354
4560
|
|
|
4355
4561
|
### Analytics (optional, server-side GA4 conversions)
|
|
4356
4562
|
|
|
4357
|
-
If the store has the **Google & YouTube** app installed with a GA4 property connected, Brainerce can send server-side `purchase` conversions via the GA4 Measurement Protocol
|
|
4563
|
+
If the store has the **Google & YouTube** app installed with a GA4 property connected, Brainerce can send server-side `purchase` conversions via the GA4 Measurement Protocol, recovering the 15-40% of conversions client-side `gtag.js` typically loses to ad-blockers, ITP/Safari cookie capping, and the post-payment redirect dropping the page before the browser beacon fires.
|
|
4358
4564
|
|
|
4359
4565
|
For the server-sent purchase to join the shopper's on-page GA4 session (instead of creating a duplicate/orphan user), it needs the same `client_id`/`session_id` gtag.js is using in the browser. Call `loadGoogleAnalytics()` once and the SDK handles the rest:
|
|
4360
4566
|
|
|
@@ -4370,8 +4576,8 @@ await client.addToCart(cart.id, { productId: 'prod_abc', quantity: 1 });
|
|
|
4370
4576
|
|
|
4371
4577
|
### Traffic attribution (automatic, zero-config)
|
|
4372
4578
|
|
|
4373
|
-
The SDK also records where each visit came from
|
|
4374
|
-
and any `utm_source`/`utm_medium`/`utm_campaign
|
|
4579
|
+
The SDK also records where each visit came from, the external referrer host
|
|
4580
|
+
and any `utm_source`/`utm_medium`/`utm_campaign`, as a **last non-direct
|
|
4375
4581
|
touch** (`brainerce_attr` in localStorage, 30-day window). The captured values
|
|
4376
4582
|
are auto-attached to `setCheckoutCustomer()` / `setShippingAddress()` and end
|
|
4377
4583
|
up on the order, powering the dashboard's "orders from ChatGPT / Google / …"
|
|
@@ -4379,12 +4585,12 @@ reporting. Nothing to configure; a value you pass explicitly always wins.
|
|
|
4379
4585
|
|
|
4380
4586
|
What this does:
|
|
4381
4587
|
|
|
4382
|
-
- Idempotently injects `gtag.js` and initializes `dataLayer` (skips injection if you're already loading `gtag.js` yourself
|
|
4383
|
-
- Resolves `client_id`/`session_id` via `gtag('get', measurementId, 'client_id' | 'session_id', cb)
|
|
4588
|
+
- Idempotently injects `gtag.js` and initializes `dataLayer` (skips injection if you're already loading `gtag.js` yourself, so it is safe to call either way).
|
|
4589
|
+
- Resolves `client_id`/`session_id` via `gtag('get', measurementId, 'client_id' | 'session_id', cb)`, Google's documented method, not `_ga` cookie-parsing (which breaks across cookie-format changes and Consent Mode v2 states).
|
|
4384
4590
|
- Auto-attaches the resolved ids to `createCart()`, `addToCart()`, `setCheckoutCustomer()`, and `setShippingAddress()`. An explicit `analyticsClientId`/`analyticsSessionId` you pass to any of those calls always wins over the auto-captured value.
|
|
4385
|
-
- Never throws and never delays a cart/checkout call by more than ~1.5s (configurable via `{ timeoutMs }`)
|
|
4591
|
+
- Never throws and never delays a cart/checkout call by more than ~1.5s (configurable via `{ timeoutMs }`). A blocked or slow `gtag.js`, or a shopper who denied analytics consent, just means the server-side conversion won't stitch. It never breaks checkout.
|
|
4386
4592
|
|
|
4387
|
-
You still need to paste the GA4 **Measurement Protocol API secret** once in the dashboard (**Apps → Google & YouTube → Analytics**)
|
|
4593
|
+
You still need to paste the GA4 **Measurement Protocol API secret** once in the dashboard (**Apps → Google & YouTube → Analytics**). Create it in GA4 Admin → Data Streams → your stream → Measurement Protocol API secrets. Without it (or without `loadGoogleAnalytics()` ever being called), the platform simply skips the server-side event. Nothing breaks; GA4 just doesn't get the extra signal.
|
|
4388
4594
|
|
|
4389
4595
|
> This is entirely optional and separate from Brainerce's [Traffic Analytics](#traffic-analytics-built-in-no-ga4-needed) above, which needs no GA4 account at all.
|
|
4390
4596
|
|
|
@@ -4394,7 +4600,7 @@ You still need to paste the GA4 **Measurement Protocol API secret** once in the
|
|
|
4394
4600
|
|
|
4395
4601
|
`initTracking()` boots every marketing tag the merchant has configured, and `trackMarketingEvent()` reports e-commerce events to all of them at once. Two calls, no per-vendor code.
|
|
4396
4602
|
|
|
4397
|
-
**You do not ask the merchant for tag ids.** They arrive in `getStoreInfo().tracking`, resolved server-side from the marketplace apps the merchant already connected
|
|
4603
|
+
**You do not ask the merchant for tag ids.** They arrive in `getStoreInfo().tracking`, resolved server-side from the marketplace apps the merchant already connected: connecting the **Google & YouTube** app runs GA4 discovery and the measurement id appears on its own; the **Meta Commerce** app does the same for the pixel. Nothing is typed, and the storefront never redeploys: a newly connected app shows up within 5 minutes.
|
|
4398
4604
|
|
|
4399
4605
|
```typescript
|
|
4400
4606
|
const storeInfo = await client.getStoreInfo();
|
|
@@ -4404,7 +4610,7 @@ const storeInfo = await client.getStoreInfo();
|
|
|
4404
4610
|
client.initTracking(storeInfo.tracking);
|
|
4405
4611
|
```
|
|
4406
4612
|
|
|
4407
|
-
Then report what the shopper did
|
|
4613
|
+
Then report what the shopper did, once, in GA4's vocabulary. The SDK translates to each vendor (`dataLayer` push, `fbq` standard event, `ttq` event):
|
|
4408
4614
|
|
|
4409
4615
|
```typescript
|
|
4410
4616
|
client.trackMarketingEvent('view_item', { currency, value: price, items: [item] });
|
|
@@ -4427,11 +4633,11 @@ client.trackMarketingEvent('purchase', {
|
|
|
4427
4633
|
|
|
4428
4634
|
Event names: `view_item` · `view_item_list` · `add_to_cart` · `remove_from_cart` · `view_cart` · `begin_checkout` · `add_payment_info` · `purchase` · `search` · `sign_up`.
|
|
4429
4635
|
|
|
4430
|
-
⛔ **`itemId` must be the SKU.** That is the id Brainerce publishes to the Google Merchant Center and Meta catalog feeds, so it is the only id the ad platforms can match a pixel event against. Sending a product or variant id instead breaks attribution and dynamic remarketing **silently
|
|
4636
|
+
⛔ **`itemId` must be the SKU.** That is the id Brainerce publishes to the Google Merchant Center and Meta catalog feeds, so it is the only id the ad platforms can match a pixel event against. Sending a product or variant id instead breaks attribution and dynamic remarketing **silently**: the events arrive, the platform reports an id its catalog has never seen, and the remarketing audience never builds.
|
|
4431
4637
|
|
|
4432
4638
|
⛔ **Pass `transactionId` on `purchase`.** It becomes GA4's `transaction_id`, Meta's `eventID` and TikTok's `event_id`, so a shopper who refreshes the confirmation page de-duplicates on the vendor's side instead of double-counting the order. Also guard the call yourself (e.g. a `sessionStorage` key per order id) so the send doesn't repeat at all.
|
|
4433
4639
|
|
|
4434
|
-
**Content-Security-Policy.** If your storefront sets a CSP, the tags load but every hit is blocked unless these hosts are in `connect-src
|
|
4640
|
+
**Content-Security-Policy.** If your storefront sets a CSP, the tags load but every hit is blocked unless these hosts are in `connect-src`, a failure that looks exactly like "no sales" in ad reporting:
|
|
4435
4641
|
|
|
4436
4642
|
```
|
|
4437
4643
|
connect-src … https://www.googletagmanager.com https://www.google-analytics.com
|
|
@@ -4441,19 +4647,19 @@ connect-src … https://www.googletagmanager.com https://www.google-analytics.co
|
|
|
4441
4647
|
https://analytics.tiktok.com
|
|
4442
4648
|
```
|
|
4443
4649
|
|
|
4444
|
-
Under `script-src 'strict-dynamic'` you do **not** add these to `script-src
|
|
4650
|
+
Under `script-src 'strict-dynamic'` you do **not** add these to `script-src`, because host allowlists are ignored there, and trust propagates from your nonce'd bundle to the tag scripts it injects.
|
|
4445
4651
|
|
|
4446
|
-
**Consent.** These are advertising tags, unlike Brainerce's cookieless [Traffic Analytics](#traffic-analytics-built-in-no-ga4-needed). If you serve the EEA/UK, gate `initTracking()` behind your consent banner or implement Google Consent Mode v2
|
|
4652
|
+
**Consent.** These are advertising tags, unlike Brainerce's cookieless [Traffic Analytics](#traffic-analytics-built-in-no-ga4-needed). If you serve the EEA/UK, gate `initTracking()` behind your consent banner or implement Google Consent Mode v2. The SDK does not do this for you.
|
|
4447
4653
|
|
|
4448
4654
|
`initTracking()` is idempotent, SSR-safe, and never throws. Both methods no-op when no tag is configured, so they're always safe to call without a feature check.
|
|
4449
4655
|
|
|
4450
|
-
> `trackMarketingEvent()` (ad platforms) is a different method from `trackEvent()` (Brainerce's own cookieless traffic analytics). Call both
|
|
4656
|
+
> `trackMarketingEvent()` (ad platforms) is a different method from `trackEvent()` (Brainerce's own cookieless traffic analytics). Call both; they answer different questions.
|
|
4451
4657
|
|
|
4452
4658
|
---
|
|
4453
4659
|
|
|
4454
4660
|
## Admin API Reference
|
|
4455
4661
|
|
|
4456
|
-
> ⛔ **Server-side only.** The `apiKey` (`brainerce_*`) is a privileged secret
|
|
4662
|
+
> ⛔ **Server-side only.** The `apiKey` (`brainerce_*`) is a privileged secret. NEVER put it in browser code, client bundles, or any code that ships to the user's machine. It belongs in an environment variable on your server. For building the customer-facing storefront, use `salesChannelId` instead (see [Quick Start](#quick-start)).
|
|
4457
4663
|
|
|
4458
4664
|
The Admin API provides full access to store configuration and management features (taxonomy, shipping, team, metafields, etc.) and runs only in server-side code.
|
|
4459
4665
|
|
|
@@ -4550,11 +4756,11 @@ await client.bulkSaveVariants(variableProduct.id, {
|
|
|
4550
4756
|
});
|
|
4551
4757
|
```
|
|
4552
4758
|
|
|
4553
|
-
**GTIN vs MPN:** these are two different identifiers, not interchangeable
|
|
4759
|
+
**GTIN vs MPN:** these are two different identifiers, not interchangeable: GTIN (EAN/UPC/ISBN) is a universal barcode; MPN is manufacturer-specific and only meaningful paired with a brand. Provide GTIN when the product has one; otherwise brand + MPN. A product typically needs one or the other, not both.
|
|
4554
4760
|
|
|
4555
4761
|
### Bulk Product Creation (catalog import)
|
|
4556
4762
|
|
|
4557
|
-
Importing a catalog
|
|
4763
|
+
Importing a catalog (a supplier feed, a CSV/Excel export, a store migration)
|
|
4558
4764
|
should not be thousands of `createProduct` calls. `bulkCreateProducts` takes an
|
|
4559
4765
|
array and returns a **job id**: the work is queued, and the products appear over
|
|
4560
4766
|
the following seconds or minutes.
|
|
@@ -4602,7 +4808,7 @@ while (status.status === 'QUEUED' || status.status === 'RUNNING') {
|
|
|
4602
4808
|
console.log(status.succeeded, status.skipped, status.failed, status.pending);
|
|
4603
4809
|
```
|
|
4604
4810
|
|
|
4605
|
-
`COMPLETED_WITH_ERRORS` means the import finished and some rows failed
|
|
4811
|
+
`COMPLETED_WITH_ERRORS` means the import finished and some rows failed. There
|
|
4606
4812
|
is nothing to re-run. Read the failures instead; each carries the 1-indexed
|
|
4607
4813
|
`row` from the array you submitted, so it maps back to the line of the source
|
|
4608
4814
|
spreadsheet:
|
|
@@ -4642,7 +4848,7 @@ chunks and leaves `finishedAt` null until every one has finished, so a partial
|
|
|
4642
4848
|
result can never read as a finished import.
|
|
4643
4849
|
|
|
4644
4850
|
**Duplicates.** A row whose `sku` or `externalId` already exists in the store is
|
|
4645
|
-
skipped rather than duplicated
|
|
4851
|
+
skipped rather than duplicated, so a batch re-sent after a timeout cannot
|
|
4646
4852
|
create the catalog twice. This is a database-level check, so it still holds days
|
|
4647
4853
|
later and across retries. Rows carrying **neither** a `sku` nor an `externalId`
|
|
4648
4854
|
have nothing to match on and will be created again on a re-send; set
|
|
@@ -4654,13 +4860,13 @@ skipped.
|
|
|
4654
4860
|
taken, the importer appends a suffix (`t-shirt`, `t-shirt-1`, ...) and imports
|
|
4655
4861
|
the row, where `createProduct` returns a 400. Rows colliding with each other
|
|
4656
4862
|
inside the same batch are resolved the same way, in submission order. That is
|
|
4657
|
-
deliberate
|
|
4863
|
+
deliberate, since a spreadsheet with two "T-Shirt" rows should import rather than fail, but
|
|
4658
4864
|
it means the slug you sent is not always the slug you get. Read it back from
|
|
4659
4865
|
the product if you depend on it.
|
|
4660
4866
|
|
|
4661
4867
|
**Channel sync.** By default (`syncMode: 'coalesced'`) the per-product push to
|
|
4662
4868
|
connected sales channels is suppressed during the import and one sync per
|
|
4663
|
-
affected channel is filed at the end
|
|
4869
|
+
affected channel is filed at the end, because connectors are rate-limited per catalog,
|
|
4664
4870
|
and a per-product fan-out would exhaust those limits. Use `syncMode: 'none'` to
|
|
4665
4871
|
write to Brainerce only.
|
|
4666
4872
|
|
|
@@ -4766,13 +4972,13 @@ const swatches = getProductSwatches(product);
|
|
|
4766
4972
|
```
|
|
4767
4973
|
|
|
4768
4974
|
> **Editing or deleting a single option is dashboard-only.** `/api/v1/attributes/:id/options`
|
|
4769
|
-
> exposes exactly two verbs
|
|
4975
|
+
> exposes exactly two verbs: `GET` (list) and `POST` (add). There is no per-option route
|
|
4770
4976
|
> on the public API, so `client.updateAttributeOption()` and `client.deleteAttributeOption()`
|
|
4771
4977
|
> return **404**. The per-option routes exist only on the dashboard API
|
|
4772
4978
|
> (`PUT` / `DELETE /api/stores/:storeId/attributes/:id/options/:optionId`), behind Clerk
|
|
4773
4979
|
> auth. Recolour a swatch or drop an option in the dashboard. Everything else on
|
|
4774
|
-
> attributes
|
|
4775
|
-
> `createAttributeOption`
|
|
4980
|
+
> attributes (create, list, `updateAttribute`, `deleteAttribute`, `getAttributeOptions`,
|
|
4981
|
+
> `createAttributeOption`) works from the SDK as shown above.
|
|
4776
4982
|
|
|
4777
4983
|
### Shipping Configuration
|
|
4778
4984
|
|
|
@@ -4835,8 +5041,8 @@ await client.createZoneShippingRate('zone_id', {
|
|
|
4835
5041
|
|
|
4836
5042
|
### App Store Shipping (live carrier rates)
|
|
4837
5043
|
|
|
4838
|
-
Once a merchant installs a shipping app from the Brainerce App Store
|
|
4839
|
-
future carrier
|
|
5044
|
+
Once a merchant installs a shipping app from the Brainerce App Store (EasyPost, Shippo, or any
|
|
5045
|
+
future carrier) and connects their own carrier account, live rates appear automatically at
|
|
4840
5046
|
checkout. Billing goes directly to the merchant's carrier account.
|
|
4841
5047
|
|
|
4842
5048
|
Every carrier app implements the same Brainerce shipping contract, so this code is identical
|
|
@@ -4858,11 +5064,11 @@ console.log(label.carrier); // e.g. 'USPS', 'UPS', 'FedEx'
|
|
|
4858
5064
|
console.log(label.labelFormat); // What the carrier actually produced
|
|
4859
5065
|
```
|
|
4860
5066
|
|
|
4861
|
-
The `trackingNumber` is stored on the `Order` automatically
|
|
5067
|
+
The `trackingNumber` is stored on the `Order` automatically, and customers see it in their
|
|
4862
5068
|
order history without any extra integration work.
|
|
4863
5069
|
|
|
4864
5070
|
**Buy the service the shopper paid for.** `order.shippingSelection` records the live carrier
|
|
4865
|
-
service that was sold at checkout
|
|
5071
|
+
service that was sold at checkout as `{ carrier, service, methodName, amount }`, or `null` when
|
|
4866
5072
|
the order sold a flat-rate/zone rate and there is nothing to match. Rate ids do not survive a
|
|
4867
5073
|
re-quote, so re-find it on `carrier` + `service`, trimmed and lower-cased:
|
|
4868
5074
|
|
|
@@ -4880,12 +5086,12 @@ const preferred = paidFor
|
|
|
4880
5086
|
|
|
4881
5087
|
Buying a cheaper, slower service than the one the shopper was charged for is a silent
|
|
4882
5088
|
downgrade of what they bought. When the paid-for service is not in the fresh quote, say so and
|
|
4883
|
-
let a human choose
|
|
5089
|
+
let a human choose, and do not substitute one automatically.
|
|
4884
5090
|
|
|
4885
5091
|
**Tracking updates are automatic.** Once the label exists, the carrier's tracking webhooks
|
|
4886
|
-
flow back through the shipping app and move the shipment through its lifecycle
|
|
5092
|
+
flow back through the shipping app and move the shipment through its lifecycle: in transit,
|
|
4887
5093
|
out for delivery, delivered. On delivery the order is completed and the customer notification
|
|
4888
|
-
fires. You never poll for status
|
|
5094
|
+
fires. You never poll for status; read the history when you want to show it:
|
|
4889
5095
|
|
|
4890
5096
|
```typescript
|
|
4891
5097
|
const shipments = await admin.getOrderShipments(orderId);
|
|
@@ -4900,9 +5106,37 @@ for (const s of shipments) {
|
|
|
4900
5106
|
```
|
|
4901
5107
|
|
|
4902
5108
|
**Quote immediately before you buy.** `getOrderShippingRates()` is what creates the shipment
|
|
4903
|
-
at the carrier
|
|
5109
|
+
at the carrier, which the rate id points at, and carriers do not allow amending one afterwards.
|
|
4904
5110
|
A rate held from an earlier call may no longer be purchasable.
|
|
4905
5111
|
|
|
5112
|
+
### Return labels
|
|
5113
|
+
|
|
5114
|
+
Buy a return label the merchant sends to their customer to print — the customer ships, the
|
|
5115
|
+
merchant receives. This is a distinct route from the rate-shop-then-buy flow above: there is
|
|
5116
|
+
no `rateId`, because a return is a different kind of shipment at the carrier, fixed as a return
|
|
5117
|
+
when it is created and never amendable afterwards. It quotes and buys in one call.
|
|
5118
|
+
|
|
5119
|
+
```typescript
|
|
5120
|
+
const label = await admin.createReturnLabel('store_abc', orderId, {
|
|
5121
|
+
reason: 'Wrong size',
|
|
5122
|
+
returnForShipmentId: 'shp_original123', // optional — the outbound shipment this reverses
|
|
5123
|
+
labelFormat: 'PDF', // 'PDF' | 'PNG' | 'ZPL' | 'EPL'
|
|
5124
|
+
preferredCarrier: 'USPS', // optional — omit both to let the cheapest rate win
|
|
5125
|
+
preferredService: 'GroundAdvantage',
|
|
5126
|
+
});
|
|
5127
|
+
|
|
5128
|
+
console.log(label.labelUrl); // Label file URL for printing
|
|
5129
|
+
console.log(label.trackingNumber);
|
|
5130
|
+
console.log(label.rate, label.rateCurrency); // What the merchant's carrier account was billed
|
|
5131
|
+
```
|
|
5132
|
+
|
|
5133
|
+
Requires admin mode (`apiKey`) with `FULFILL_ORDERS` permission — it spends the store's carrier
|
|
5134
|
+
balance, same as `createShippingLabel`. Unlike the other shipment calls on this page, this one
|
|
5135
|
+
takes `storeId` as an explicit first argument rather than deriving it from the API key, so pass
|
|
5136
|
+
it even in admin mode. There is no mechanism for charging the customer for return postage —
|
|
5137
|
+
`rate` reports what the merchant's account paid so it can be deducted from a refund
|
|
5138
|
+
deliberately, never absorbed silently.
|
|
5139
|
+
|
|
4906
5140
|
### Cross-border shipments
|
|
4907
5141
|
|
|
4908
5142
|
Customs declarations are handled for you: the platform builds one from the order's line items
|
|
@@ -4912,8 +5146,8 @@ merchandise.
|
|
|
4912
5146
|
|
|
4913
5147
|
One case needs the merchant: a US-origin export where any single commodity line exceeds
|
|
4914
5148
|
**$2,500** cannot use the ordinary EEI exemption. The exporter must file with AES and supply
|
|
4915
|
-
the resulting ITN. Brainerce deliberately does **not** assert the exemption on those shipments
|
|
4916
|
-
|
|
5149
|
+
the resulting ITN. Brainerce deliberately does **not** assert the exemption on those shipments.
|
|
5150
|
+
It is a declaration to US Customs, not a formality, so the carrier will refuse the label until
|
|
4917
5151
|
a real citation is provided.
|
|
4918
5152
|
|
|
4919
5153
|
### Tax Configuration
|
|
@@ -4985,7 +5219,7 @@ await client.deleteTaxClass(food.id);
|
|
|
4985
5219
|
|
|
4986
5220
|
**Storefront (public, no API key).** A storefront lists classes in `storeId`
|
|
4987
5221
|
mode **or** vibe-coded mode (`salesChannelId: 'vc_*'`, gated on the
|
|
4988
|
-
`products:read` scope every connection already has)
|
|
5222
|
+
`products:read` scope every connection already has), returning storefront-safe fields
|
|
4989
5223
|
only (for a "9% VAT" transparency badge):
|
|
4990
5224
|
|
|
4991
5225
|
```typescript
|
|
@@ -5050,7 +5284,7 @@ await client.deleteRegion(eu.id);
|
|
|
5050
5284
|
|
|
5051
5285
|
**Storefront (public, no API key).** A storefront fetches regions in `storeId`
|
|
5052
5286
|
mode **or** vibe-coded mode (`salesChannelId: 'vc_*'`, gated on the
|
|
5053
|
-
`products:read` scope every connection already has)
|
|
5287
|
+
`products:read` scope every connection already has), returning only active regions and only
|
|
5054
5288
|
storefront-safe fields. `getStoreRegions()`, `getStoreRegion()`, and
|
|
5055
5289
|
`getAutoRegion()` all work in both modes:
|
|
5056
5290
|
|
|
@@ -5100,18 +5334,18 @@ await client.setMetafieldPlatforms('def_id', {
|
|
|
5100
5334
|
|
|
5101
5335
|
### Per-Channel Publishing (Categories / Tags / Brands / Custom Fields)
|
|
5102
5336
|
|
|
5103
|
-
Each of these entities can be **gated per vibe-coded site
|
|
5337
|
+
Each of these entities can be **gated per vibe-coded site**: merchants
|
|
5104
5338
|
choose which storefronts see which categories, tags, brands, and custom
|
|
5105
5339
|
fields. Mirrors the pattern already used by Products and Coupons.
|
|
5106
5340
|
|
|
5107
|
-
**Visibility semantics
|
|
5341
|
+
**Visibility semantics, explicit opt-in:** an entity is visible to a
|
|
5108
5342
|
vibe-coded site **only if** it has been explicitly published to that
|
|
5109
5343
|
connection. Entities with no publish rows are invisible to every vibe-coded
|
|
5110
5344
|
site, including in product responses (the related categories/brands/tags
|
|
5111
5345
|
arrays and the metafields array on each product are filtered the same way).
|
|
5112
5346
|
Merchants publish through the dashboard's per-row Platforms cell; for
|
|
5113
5347
|
categories, tags and brands the admin SDK below does the same thing. Custom
|
|
5114
|
-
fields are the exception
|
|
5348
|
+
fields are the exception; see the note under the snippet.
|
|
5115
5349
|
|
|
5116
5350
|
```typescript
|
|
5117
5351
|
// Publish a product to a sales channel (accepts record ID or vc_* connection ID)
|
|
@@ -5137,21 +5371,22 @@ const cat = await client.getCategory('cat_id');
|
|
|
5137
5371
|
cat.channelPublishes; // [{ salesChannel: { id, name, connectionId } }, ...]
|
|
5138
5372
|
```
|
|
5139
5373
|
|
|
5140
|
-
> **Custom fields are the exception
|
|
5374
|
+
> **Custom fields are the exception: publish them in the dashboard.**
|
|
5141
5375
|
> `client.publishMetafieldDefinitionToVibeCodedSite()` and
|
|
5142
5376
|
> `client.unpublishMetafieldDefinitionFromVibeCodedSite()` target
|
|
5143
5377
|
> `/api/v1/metafield-definitions/:id/publish-vibe-coded`, which the public API does not
|
|
5144
5378
|
> expose; both return **404**. Only the dashboard API carries those routes
|
|
5145
5379
|
> (`POST /api/stores/:storeId/metafield-definitions/:id/publish` and `…/unpublish`),
|
|
5146
|
-
> behind Clerk auth. **Reading is unaffected
|
|
5380
|
+
> behind Clerk auth. **Reading is unaffected**: `getMetafieldDefinitions()` and
|
|
5147
5381
|
> `getMetafieldDefinition()` both return `channelPublishes` exactly like the other three
|
|
5148
5382
|
> entity types, so you can still see which sites a custom field is published to; you just
|
|
5149
5383
|
> cannot change it from the SDK.
|
|
5150
5384
|
|
|
5151
5385
|
> **`vibeCodedPublishes` and its `connection` sub-key are deprecated.** Both are
|
|
5152
|
-
> still emitted as back-compat aliases of `channelPublishes` /
|
|
5153
|
-
> are
|
|
5154
|
-
> the customer section below
|
|
5386
|
+
> still emitted as permanent back-compat aliases of `channelPublishes` /
|
|
5387
|
+
> `salesChannel` — they are not scheduled for removal. Read
|
|
5388
|
+
> `channelPublishes[].salesChannel` in new code; the customer section below
|
|
5389
|
+
> already does.
|
|
5155
5390
|
|
|
5156
5391
|
**Cross-account isolation:** publishing only succeeds when the entity and the
|
|
5157
5392
|
target vibe-coded connection both belong to the same account. Cross-account
|
|
@@ -5162,12 +5397,12 @@ exist for that account).
|
|
|
5162
5397
|
|
|
5163
5398
|
Customers use the same publish/unpublish shape, with one important difference:
|
|
5164
5399
|
**you almost never have to call it.** A customer is attached to a channel
|
|
5165
|
-
automatically the moment they are seen on it
|
|
5400
|
+
automatically the moment they are seen on it, when they register, sign in
|
|
5166
5401
|
(including via OAuth), or complete a checkout there.
|
|
5167
5402
|
|
|
5168
5403
|
There is one customer record per store, shared by every channel
|
|
5169
5404
|
(`@@unique(storeId, email)`), so the same person shopping two of your
|
|
5170
|
-
storefronts stays one customer with two channel rows
|
|
5405
|
+
storefronts stays one customer with two channel rows, never a duplicate.
|
|
5171
5406
|
|
|
5172
5407
|
```typescript
|
|
5173
5408
|
// Attach / detach by hand — for migrations and corrections only
|
|
@@ -5198,7 +5433,7 @@ not prevent that person from buying on that storefront, and the row comes back
|
|
|
5198
5433
|
the next time they sign in or order there. There is no API to bar a customer
|
|
5199
5434
|
from a sales channel.
|
|
5200
5435
|
|
|
5201
|
-
### Store Team Management
|
|
5436
|
+
### Store Team Management: dashboard-only
|
|
5202
5437
|
|
|
5203
5438
|
Each store has its own team with roles (`OWNER`, `MANAGER`, `STAFF`, `VIEWER`) and
|
|
5204
5439
|
granular permissions, including per-sales-channel scoping. **Managing it is a dashboard
|
|
@@ -5221,12 +5456,12 @@ members in the dashboard.
|
|
|
5221
5456
|
|
|
5222
5457
|
> **The older account-level methods are not a substitute for this.** `getTeamMembers`,
|
|
5223
5458
|
> `getTeamInvitations`, `inviteTeamMember`, `resendTeamInvitation`, `revokeTeamInvitation`,
|
|
5224
|
-
> `updateTeamMemberRole` and `removeTeamMember` do still reach `/api/v1/team
|
|
5459
|
+
> `updateTeamMemberRole` and `removeTeamMember` do still reach `/api/v1/team/…`, but they
|
|
5225
5460
|
> manage the **account** team, not a store's. They will not invite anyone to a store or
|
|
5226
5461
|
> scope a member to a sales channel; only the dashboard does that.
|
|
5227
5462
|
>
|
|
5228
5463
|
> **For the account team, they remain the supported call.** All seven are tagged
|
|
5229
|
-
> `@deprecated`, which records an intent to retire them
|
|
5464
|
+
> `@deprecated`, which records an intent to retire them, not a migration you can perform
|
|
5230
5465
|
> today. There is no API-key replacement: the store-level methods named above are
|
|
5231
5466
|
> dashboard-only. Keep using these until an API-key route ships, and expect the tag to
|
|
5232
5467
|
> outlive this note.
|
|
@@ -5390,7 +5625,117 @@ await client.updateAttachment(storeId, productId, attachment.id, { position: 1 }
|
|
|
5390
5625
|
await client.detachModifierGroup(storeId, productId, attachment.id);
|
|
5391
5626
|
```
|
|
5392
5627
|
|
|
5393
|
-
`null` on an override means "inherit from the group default"; any non-null value (including `0` or `false`) wins. `modifierGroupId` and `variantId` are immutable on `updateAttachment
|
|
5628
|
+
`null` on an override means "inherit from the group default"; any non-null value (including `0` or `false`) wins. `modifierGroupId` and `variantId` are immutable on `updateAttachment`; to swap a group, detach and re-attach.
|
|
5629
|
+
|
|
5630
|
+
### Review Moderation
|
|
5631
|
+
|
|
5632
|
+
Five admin methods for moderating customer reviews and the photos attached to
|
|
5633
|
+
them. Listing needs an API key with `reviews:read`; hiding and showing need
|
|
5634
|
+
`reviews:write`. The storefront methods are covered under [Product Reviews](#product-reviews).
|
|
5635
|
+
|
|
5636
|
+
`storeId` is optional on every call: pass it when the key can reach more than
|
|
5637
|
+
one store.
|
|
5638
|
+
|
|
5639
|
+
```typescript
|
|
5640
|
+
// Every review on the product, including the hidden ones the storefront omits.
|
|
5641
|
+
// visibility: 'visible' | 'hidden' | 'all' (default 'all').
|
|
5642
|
+
const { data, meta } = await client.adminListProductReviews('prod_123', {
|
|
5643
|
+
page: 1,
|
|
5644
|
+
limit: 20,
|
|
5645
|
+
visibility: 'hidden',
|
|
5646
|
+
storeId, // optional
|
|
5647
|
+
});
|
|
5648
|
+
|
|
5649
|
+
// ProductReviewAdmin carries the PII the storefront type does not:
|
|
5650
|
+
// customerId, authorEmail, orderId, updatedAt, hiddenAt, plus `images` typed as
|
|
5651
|
+
// ProductReviewImageAdmin[] (assetKey, approvedAt, hiddenAt, createdAt).
|
|
5652
|
+
data.forEach((review) => {
|
|
5653
|
+
console.log(review.authorEmail, review.orderId, review.hiddenAt);
|
|
5654
|
+
});
|
|
5655
|
+
```
|
|
5656
|
+
|
|
5657
|
+
```typescript
|
|
5658
|
+
// Hide a whole review, then put it back. Hiding stamps hiddenAt; showing clears it.
|
|
5659
|
+
await client.hideProductReview('rev_123', storeId);
|
|
5660
|
+
await client.showProductReview('rev_123', storeId);
|
|
5661
|
+
```
|
|
5662
|
+
|
|
5663
|
+
```typescript
|
|
5664
|
+
// Photo-level moderation: take down ONE photo and leave the review, its rating
|
|
5665
|
+
// and its other photos live.
|
|
5666
|
+
await client.hideProductReviewImage('revimg_123', storeId);
|
|
5667
|
+
|
|
5668
|
+
// Showing a photo is ALSO the approve action. A photo that has never been
|
|
5669
|
+
// approved carries `approvedAt: null`, and showing it stamps one, so stores that
|
|
5670
|
+
// turned on review-photo approval need no separate verb.
|
|
5671
|
+
await client.showProductReviewImage('revimg_123', storeId);
|
|
5672
|
+
```
|
|
5673
|
+
|
|
5674
|
+
> **Reviews publish immediately.** There is no pending state for the review text
|
|
5675
|
+
> itself, so a moderation queue built on "approve each review" has nothing to
|
|
5676
|
+
> read. Photos are the only part that can wait on the merchant, and only on
|
|
5677
|
+
> stores that turned approval on. Poll `adminListProductReviews(productId, { visibility: 'all' })`
|
|
5678
|
+
> and treat any image with `approvedAt: null` as the queue.
|
|
5679
|
+
|
|
5680
|
+
### Translations Management
|
|
5681
|
+
|
|
5682
|
+
Six admin methods for reading and writing the same `translations` JSON blob the
|
|
5683
|
+
dashboard's own translation editor uses — this is API-key access to the
|
|
5684
|
+
dashboard's persistence path, not a parallel system. Use it to bulk-import
|
|
5685
|
+
pre-translated content (e.g. from a TMS export) or to automate locale
|
|
5686
|
+
coverage without a human opening the dashboard. Needs `products:read` /
|
|
5687
|
+
`products:write` (or the equivalent scope for the target entity type).
|
|
5688
|
+
|
|
5689
|
+
Covers 18 entity types: `store`, `product`, `category`, `brand`, `tag`,
|
|
5690
|
+
`variant`, `attribute`, `attributeOption`, `metafield`, `metafieldDefinition`,
|
|
5691
|
+
`contactForm`, `contactFormField`, `modifierGroup`, `modifier`, `bundleOffer`,
|
|
5692
|
+
`orderBump`, `discountRule`, `blogPost`.
|
|
5693
|
+
|
|
5694
|
+
```typescript
|
|
5695
|
+
// Pre-flight: which entity types/locales still need coverage?
|
|
5696
|
+
const status = await client.getTranslationStatus(storeId, ['he', 'fr']);
|
|
5697
|
+
const blogHe = status.find((s) => s.entityType === 'blogPost' && s.locale === 'he');
|
|
5698
|
+
console.log(`${blogHe?.missing} blog posts still need Hebrew`);
|
|
5699
|
+
|
|
5700
|
+
// Read every persisted translation for one entity
|
|
5701
|
+
const translations = await client.getTranslations(storeId, 'product', productId);
|
|
5702
|
+
console.log(translations.he?.name);
|
|
5703
|
+
|
|
5704
|
+
// Bulk-import a pre-translated blog post (e.g. from a TMS export). Only the
|
|
5705
|
+
// fields valid for `entityType` are persisted — others are silently ignored —
|
|
5706
|
+
// and this is a merge: omitted fields leave any existing translation untouched.
|
|
5707
|
+
await client.setTranslation(storeId, 'blogPost', postId, 'fr', {
|
|
5708
|
+
title: 'Le titre en français',
|
|
5709
|
+
excerpt: "L'extrait en français",
|
|
5710
|
+
content: '<p>Le contenu en français</p>',
|
|
5711
|
+
});
|
|
5712
|
+
|
|
5713
|
+
// Remove one locale's translation (base fields are unaffected)
|
|
5714
|
+
await client.deleteTranslation(storeId, 'blogPost', postId, 'fr');
|
|
5715
|
+
|
|
5716
|
+
// AI-translate a single entity inline — only fills fields still empty for
|
|
5717
|
+
// the target locale; never overwrites an existing translated value.
|
|
5718
|
+
const result = await client.aiTranslateSingle(storeId, {
|
|
5719
|
+
entityType: 'product',
|
|
5720
|
+
entityId: productId,
|
|
5721
|
+
targetLocale: 'he',
|
|
5722
|
+
});
|
|
5723
|
+
|
|
5724
|
+
// Bulk AI-translate: enqueues a background job per entity (returns the count
|
|
5725
|
+
// queued, not finished translations — poll getTranslationStatus for results).
|
|
5726
|
+
// Omit entityIds to target every entity of that type missing the locale.
|
|
5727
|
+
const { queued } = await client.aiTranslateBulk(storeId, {
|
|
5728
|
+
entityType: 'blogPost',
|
|
5729
|
+
targetLocale: 'fr',
|
|
5730
|
+
});
|
|
5731
|
+
```
|
|
5732
|
+
|
|
5733
|
+
> `aiTranslateBulk` only accepts entity types with a clear store scope:
|
|
5734
|
+
> `product`, `category`, `brand`, `tag`, `attribute`, `modifierGroup`,
|
|
5735
|
+
> `metafieldDefinition`, `blogPost`. The remaining entity types (e.g. `variant`,
|
|
5736
|
+
> `attributeOption`) are still reachable via `setTranslation` /
|
|
5737
|
+
> `aiTranslateSingle` — they translate alongside their parent instead of as a
|
|
5738
|
+
> top-level bulk target.
|
|
5394
5739
|
|
|
5395
5740
|
---
|
|
5396
5741
|
|
|
@@ -5787,7 +6132,7 @@ export default function CartPage() {
|
|
|
5787
6132
|
|
|
5788
6133
|
### Checkout Page
|
|
5789
6134
|
|
|
5790
|
-
> **RECOMMENDED:** Use this unified pattern
|
|
6135
|
+
> **RECOMMENDED:** Use this unified pattern; the `smart*` methods handle both guest and logged-in users.
|
|
5791
6136
|
|
|
5792
6137
|
```typescript
|
|
5793
6138
|
'use client';
|
|
@@ -5804,12 +6149,14 @@ export default function CheckoutPage() {
|
|
|
5804
6149
|
const [checkout, setCheckout] = useState<Checkout | null>(null);
|
|
5805
6150
|
const [shippingRates, setShippingRates] = useState<ShippingRate[]>([]);
|
|
5806
6151
|
const [selectedRate, setSelectedRate] = useState<string | null>(null);
|
|
6152
|
+
// Two phases: collect the address, then let the shopper pick a delivery option.
|
|
6153
|
+
const [step, setStep] = useState<'address' | 'shipping'>('address');
|
|
5807
6154
|
const customerLoggedIn = isLoggedIn();
|
|
5808
6155
|
|
|
5809
|
-
// Form state
|
|
5810
|
-
|
|
6156
|
+
// Form state. `email` lives here because setShippingAddress requires it on
|
|
6157
|
+
// every call, logged-in shoppers included.
|
|
5811
6158
|
const [shippingAddress, setShippingAddress] = useState({
|
|
5812
|
-
firstName: '', lastName: '', line1: '', city: '', postalCode: '', country: 'US'
|
|
6159
|
+
email: '', firstName: '', lastName: '', line1: '', city: '', postalCode: '', country: 'US'
|
|
5813
6160
|
});
|
|
5814
6161
|
|
|
5815
6162
|
useEffect(() => {
|
|
@@ -5839,7 +6186,8 @@ export default function CheckoutPage() {
|
|
|
5839
6186
|
initCheckout();
|
|
5840
6187
|
}, []);
|
|
5841
6188
|
|
|
5842
|
-
|
|
6189
|
+
// Phase 1: save the customer and the address, then show the rate picker.
|
|
6190
|
+
const handleAddressSubmit = async (e: React.FormEvent) => {
|
|
5843
6191
|
e.preventDefault();
|
|
5844
6192
|
if (!checkout) return;
|
|
5845
6193
|
setSubmitting(true);
|
|
@@ -5847,19 +6195,33 @@ export default function CheckoutPage() {
|
|
|
5847
6195
|
try {
|
|
5848
6196
|
// 1. Set customer info
|
|
5849
6197
|
await client.setCheckoutCustomer(checkout.id, {
|
|
5850
|
-
email,
|
|
6198
|
+
email: shippingAddress.email,
|
|
5851
6199
|
firstName: shippingAddress.firstName,
|
|
5852
6200
|
lastName: shippingAddress.lastName,
|
|
5853
6201
|
});
|
|
5854
6202
|
|
|
5855
|
-
// 2. Set shipping address
|
|
5856
|
-
|
|
6203
|
+
// 2. Set shipping address. The same call returns the rates for that
|
|
6204
|
+
// address, so store them instead of fetching a second time.
|
|
6205
|
+
const { rates } = await client.setShippingAddress(checkout.id, shippingAddress);
|
|
6206
|
+
setShippingRates(rates);
|
|
6207
|
+
setSelectedRate(rates[0]?.id ?? null); // preselect, the shopper can change it
|
|
6208
|
+
setStep('shipping');
|
|
6209
|
+
} catch (error) {
|
|
6210
|
+
console.error('Could not price shipping:', error);
|
|
6211
|
+
alert('We could not load shipping options for that address.');
|
|
6212
|
+
} finally {
|
|
6213
|
+
setSubmitting(false);
|
|
6214
|
+
}
|
|
6215
|
+
};
|
|
5857
6216
|
|
|
5858
|
-
|
|
5859
|
-
|
|
5860
|
-
|
|
5861
|
-
|
|
5862
|
-
|
|
6217
|
+
// Phase 2: the shopper has actually chosen a rate, so persist it and complete.
|
|
6218
|
+
const handlePlaceOrder = async () => {
|
|
6219
|
+
if (!checkout || !selectedRate) return;
|
|
6220
|
+
setSubmitting(true);
|
|
6221
|
+
|
|
6222
|
+
try {
|
|
6223
|
+
// 3. Persist the chosen rate
|
|
6224
|
+
await client.selectShippingMethod(checkout.id, selectedRate);
|
|
5863
6225
|
|
|
5864
6226
|
// 4. Complete checkout
|
|
5865
6227
|
const { orderId } = await client.completeCheckout(checkout.id);
|
|
@@ -5878,28 +6240,42 @@ export default function CheckoutPage() {
|
|
|
5878
6240
|
|
|
5879
6241
|
if (loading) return <div>Loading checkout...</div>;
|
|
5880
6242
|
|
|
6243
|
+
if (step === 'shipping') {
|
|
6244
|
+
return (
|
|
6245
|
+
<div>
|
|
6246
|
+
<h2>Delivery</h2>
|
|
6247
|
+
{shippingRates.length === 0 ? (
|
|
6248
|
+
<p>We cannot ship to that address. Please go back and edit it.</p>
|
|
6249
|
+
) : (
|
|
6250
|
+
<select value={selectedRate || ''} onChange={(e) => setSelectedRate(e.target.value)}>
|
|
6251
|
+
{shippingRates.map((rate) => (
|
|
6252
|
+
// speedTier for live carrier rates; the merchant's own name for zone rates
|
|
6253
|
+
<option key={rate.id} value={rate.id}>{rate.speedTier ? TIER_LABELS[rate.speedTier] : rate.name} - ${rate.price}</option>
|
|
6254
|
+
))}
|
|
6255
|
+
</select>
|
|
6256
|
+
)}
|
|
6257
|
+
|
|
6258
|
+
<button type="button" onClick={() => setStep('address')} disabled={submitting}>Edit address</button>
|
|
6259
|
+
<button type="button" onClick={handlePlaceOrder} disabled={submitting || !selectedRate}>
|
|
6260
|
+
{submitting ? 'Processing...' : 'Place Order'}
|
|
6261
|
+
</button>
|
|
6262
|
+
</div>
|
|
6263
|
+
);
|
|
6264
|
+
}
|
|
6265
|
+
|
|
5881
6266
|
return (
|
|
5882
|
-
<form onSubmit={
|
|
5883
|
-
{
|
|
5884
|
-
|
|
5885
|
-
)}
|
|
6267
|
+
<form onSubmit={handleAddressSubmit}>
|
|
6268
|
+
{/* Always collect the email. setShippingAddress rejects a blank one, and
|
|
6269
|
+
it is validated before any server-side lookup of the logged-in profile. */}
|
|
6270
|
+
<input type="email" value={shippingAddress.email} onChange={(e) => setShippingAddress({...shippingAddress, email: e.target.value})} placeholder="Email" required />
|
|
5886
6271
|
<input value={shippingAddress.firstName} onChange={(e) => setShippingAddress({...shippingAddress, firstName: e.target.value})} placeholder="First Name" required />
|
|
5887
6272
|
<input value={shippingAddress.lastName} onChange={(e) => setShippingAddress({...shippingAddress, lastName: e.target.value})} placeholder="Last Name" required />
|
|
5888
6273
|
<input value={shippingAddress.line1} onChange={(e) => setShippingAddress({...shippingAddress, line1: e.target.value})} placeholder="Address" required />
|
|
5889
6274
|
<input value={shippingAddress.city} onChange={(e) => setShippingAddress({...shippingAddress, city: e.target.value})} placeholder="City" required />
|
|
5890
6275
|
<input value={shippingAddress.postalCode} onChange={(e) => setShippingAddress({...shippingAddress, postalCode: e.target.value})} placeholder="Postal Code" required />
|
|
5891
6276
|
|
|
5892
|
-
{shippingRates.length > 0 && (
|
|
5893
|
-
<select value={selectedRate || ''} onChange={(e) => setSelectedRate(e.target.value)}>
|
|
5894
|
-
{shippingRates.map((rate) => (
|
|
5895
|
-
// speedTier for live carrier rates; the merchant's own name for zone rates
|
|
5896
|
-
<option key={rate.id} value={rate.id}>{rate.speedTier ? TIER_LABELS[rate.speedTier] : rate.name} - ${rate.price}</option>
|
|
5897
|
-
))}
|
|
5898
|
-
</select>
|
|
5899
|
-
)}
|
|
5900
|
-
|
|
5901
6277
|
<button type="submit" disabled={submitting}>
|
|
5902
|
-
{submitting ? '
|
|
6278
|
+
{submitting ? 'Loading delivery options...' : 'Continue to delivery'}
|
|
5903
6279
|
</button>
|
|
5904
6280
|
</form>
|
|
5905
6281
|
);
|
|
@@ -5909,6 +6285,9 @@ export default function CheckoutPage() {
|
|
|
5909
6285
|
> **Key Points:**
|
|
5910
6286
|
>
|
|
5911
6287
|
> - Both guests and logged-in users go through `createCheckout()` → `completeCheckout()`
|
|
6288
|
+
> - `setShippingAddress()` returns the rates for the address it just saved. Put them in state; a separate `getShippingRates()` call is not needed.
|
|
6289
|
+
> - Split the page into two phases. Fetching rates and completing the order in one submit means the shopper never gets to choose, and you silently charge whichever rate came back first.
|
|
6290
|
+
> - `email` is required on `setShippingAddress`, for logged-in shoppers too. It is validated before any service code runs, so the server cannot fill it in from the customer record.
|
|
5912
6291
|
> - Guest session cart is created automatically by `smart*` methods
|
|
5913
6292
|
> - Call `client.onCheckoutComplete()` after successful payment to clear the session cart
|
|
5914
6293
|
> - Call `client.syncCartOnLogin()` when a user logs in to merge their guest cart
|
|
@@ -5978,6 +6357,7 @@ export default function CheckoutPage() {
|
|
|
5978
6357
|
setSubmitting(true);
|
|
5979
6358
|
try {
|
|
5980
6359
|
const { rates } = await client.setShippingAddress(checkout.id, {
|
|
6360
|
+
email, // required on every call, even after setCheckoutCustomer
|
|
5981
6361
|
firstName, lastName,
|
|
5982
6362
|
line1: address,
|
|
5983
6363
|
city, postalCode, country,
|
|
@@ -6355,7 +6735,7 @@ try {
|
|
|
6355
6735
|
|
|
6356
6736
|
Submit contact-form messages from your storefront. Inquiries show up for the merchant at `Customers → Inquiries` in the dashboard; the merchant's reply is emailed back to the customer.
|
|
6357
6737
|
|
|
6358
|
-
**Simple (legacy
|
|
6738
|
+
**Simple (legacy, always supported):**
|
|
6359
6739
|
|
|
6360
6740
|
```typescript
|
|
6361
6741
|
await brainerce.createInquiry({
|
|
@@ -6385,7 +6765,7 @@ const forms = await brainerce.contactForms.list();
|
|
|
6385
6765
|
// → [{ key, name, isDefault }, ...]
|
|
6386
6766
|
```
|
|
6387
6767
|
|
|
6388
|
-
**Rate limit:** 3 submissions per 60 seconds per IP. Include a hidden honeypot field (and do not submit it)
|
|
6768
|
+
**Rate limit:** 3 submissions per 60 seconds per IP. Include a hidden honeypot field (and do not submit it), since bots that auto-fill every input will be rejected.
|
|
6389
6769
|
|
|
6390
6770
|
**A form keyed `newsletter` is still an inquiry.** The key is a label, not a behaviour: the submission files a message and never touches marketing consent, so that address can never receive a campaign. For a mailing list, use [Newsletter Signup](#newsletter-signup-marketing-opt-in).
|
|
6391
6771
|
|
|
@@ -6407,17 +6787,17 @@ await brainerce.marketing.subscribe({
|
|
|
6407
6787
|
|
|
6408
6788
|
Also accepts `firstName`, `lastName`, and `sourceMetadata` (referrer, UTM params, the page the popup fired on).
|
|
6409
6789
|
|
|
6410
|
-
**⛔ It does not subscribe anyone.** The contact is created and mailed a confirmation link; the address is unmailable
|
|
6790
|
+
**⛔ It does not subscribe anyone.** The contact is created and mailed a confirmation link; the address is unmailable, and invisible to every campaign audience, until the recipient clicks it. Render **"Check your email to confirm, including your spam folder"** on success, never "You're subscribed". The spam-folder half matters: a confirmation filtered there is the commonest reason a signup never converts, and the 24-hour resend cooldown means no second copy arrives. Single opt-in is not available: without the click, anyone could subscribe anyone else's address.
|
|
6411
6791
|
|
|
6412
|
-
**⛔ The response carries no information.** `{ ok: true }` is returned identically for a brand-new address, one that confirmed months ago, one inside its 24-hour resend cooldown, and one suppressed after a hard bounce
|
|
6792
|
+
**⛔ The response carries no information.** `{ ok: true }` is returned identically for a brand-new address, one that confirmed months ago, one inside its 24-hour resend cooldown, and one suppressed after a hard bounce. Otherwise the form would become a way to test who shops at this store. Show one message for every success; there is no branch to write.
|
|
6413
6793
|
|
|
6414
|
-
**Rate limit:** 3 requests per 60 seconds per IP, plus one confirmation email per address per store per 24 hours. A submission inside that cooldown still returns `{ ok: true }` and silently sends nothing
|
|
6794
|
+
**Rate limit:** 3 requests per 60 seconds per IP, plus one confirmation email per address per store per 24 hours. A submission inside that cooldown still returns `{ ok: true }` and silently sends nothing; do not treat it as a failure or retry it.
|
|
6415
6795
|
|
|
6416
6796
|
**Locale:** pass it on a multi-language storefront, or the confirmation email falls back to the store's language. `he` and `en` are written; anything else gets English.
|
|
6417
6797
|
|
|
6418
6798
|
**No discount code is minted.** For a "10% off your first order" popup, the merchant creates one coupon with the `customer_first_order` condition and you display that fixed code after a successful call.
|
|
6419
6799
|
|
|
6420
|
-
The contact appears at `Customers` in the dashboard immediately, with **Accepts marketing** off; it flips on at confirmation. It is an ordinary guest customer record
|
|
6800
|
+
The contact appears at `Customers` in the dashboard immediately, with **Accepts marketing** off; it flips on at confirmation. It is an ordinary guest customer record with no password and no account, and is the same row if that person later registers or checks out.
|
|
6421
6801
|
|
|
6422
6802
|
---
|
|
6423
6803
|
|
|
@@ -6436,9 +6816,9 @@ await brainerce.stockAlerts.subscribe({
|
|
|
6436
6816
|
// → { ok: true }
|
|
6437
6817
|
```
|
|
6438
6818
|
|
|
6439
|
-
**⛔ It is not a subscription.** One email, about one item, carrying a link that stops it. No customer account is created and no marketing consent is granted. Label the button **"Email me when it's back"**, never "Subscribe"
|
|
6819
|
+
**⛔ It is not a subscription.** One email, about one item, carrying a link that stops it. No customer account is created and no marketing consent is granted. Label the button **"Email me when it's back"**, never "Subscribe", and because it grants no consent, never hide it from a shopper who unsubscribed from your marketing.
|
|
6440
6820
|
|
|
6441
|
-
**⛔ Render it only when `getStoreInfo().stockAlertsEnabled !== false`, the item is out of stock, AND it cannot be backordered.** Requests for anything else
|
|
6821
|
+
**⛔ Render it only when `getStoreInfo().stockAlertsEnabled !== false`, the item is out of stock, AND it cannot be backordered.** Requests for anything else (a storefront whose merchant switched the feature off, an in-stock item, a backorderable one, an untracked one, an unknown product id) are silently ignored, so a button in the wrong place looks like it worked and does nothing.
|
|
6442
6822
|
|
|
6443
6823
|
```typescript
|
|
6444
6824
|
const store = await brainerce.getStoreInfo();
|
|
@@ -6454,11 +6834,11 @@ const canOfferStockAlert =
|
|
|
6454
6834
|
|
|
6455
6835
|
`backorderMode` is on `InventoryInfo` from SDK 1.61; older backends omit it, so treat `undefined` as `'NONE'`.
|
|
6456
6836
|
|
|
6457
|
-
The merchant controls the switch
|
|
6837
|
+
The merchant controls the switch, and how many people are emailed per unit restocked, under **Channel settings → Inventory**, alongside the low-stock warning.
|
|
6458
6838
|
|
|
6459
6839
|
**⛔ Pass `variantId` on every variable product.** Without it the alert waits on the product as a whole, so a shopper who wanted the medium is mailed when the small returns and arrives to find their size still gone.
|
|
6460
6840
|
|
|
6461
|
-
**⛔ The response carries no information.** `{ ok: true }` is returned identically for a new request, a duplicate, an unknown product, an item already in stock, and an address suppressed after a hard bounce
|
|
6841
|
+
**⛔ The response carries no information.** `{ ok: true }` is returned identically for a new request, a duplicate, an unknown product, an item already in stock, and an address suppressed after a hard bounce. Otherwise the button would become a way to read the store's stock levels. Show one message for every success; there is no branch to write.
|
|
6462
6842
|
|
|
6463
6843
|
**Sending is not immediate, and not to everyone.** Availability is `total - reserved`, so an expiring cart briefly lifts a sold-out item above zero; the alert waits for stock to hold for a few minutes, then goes out in waves sized to the units that came back (500 waiting and 3 units restocked is roughly 9 emails, oldest request first). A shopper can therefore sit through a restock without hearing, so never promise "you'll be the first to know".
|
|
6464
6844
|
|
|
@@ -6466,7 +6846,7 @@ The merchant controls the switch — and how many people are emailed per unit re
|
|
|
6466
6846
|
|
|
6467
6847
|
**Locale:** pass it on a multi-language storefront, or the alert falls back to the store's language. `he` and `en` are written; anything else gets English.
|
|
6468
6848
|
|
|
6469
|
-
**What it does not do:** no SMS or WhatsApp, no price-drop alerts, and no merchant-editable template
|
|
6849
|
+
**What it does not do:** no SMS or WhatsApp, no price-drop alerts, and no merchant-editable template. The body is fixed so it can never start carrying a discount code, which would turn a transactional message into a marketing one needing an unsubscribe link it does not have.
|
|
6470
6850
|
|
|
6471
6851
|
The merchant reads the demand at `Products → Back-in-Stock Waitlist`: products ranked by how many people are waiting, with the addresses behind each number. There is no way to mail those people anything else from there, by design.
|
|
6472
6852
|
|
|
@@ -6474,7 +6854,7 @@ The merchant reads the demand at `Products → Back-in-Stock Waitlist`: products
|
|
|
6474
6854
|
|
|
6475
6855
|
## Storefront Bot (AI chat widget)
|
|
6476
6856
|
|
|
6477
|
-
Add the store's AI shopping assistant with one line. Configuration (name, avatar, colors, greeting, starter questions, guardrails) is normally set in the merchant dashboard
|
|
6857
|
+
Add the store's AI shopping assistant with one line. Configuration (name, avatar, colors, greeting, starter questions, guardrails) is normally set in the merchant dashboard, and the widget renders nothing until the bot is switched Live there. Those same settings are also readable/writable via `client.getBotSettings()` / `client.updateBotSettings()`, and conversation transcripts + summarization via `client.listBotConversations()` / `client.summarizeBotConversation()`. See [Storefront Bot Settings](#storefront-bot-settings) and [Storefront Bot Conversations](#storefront-bot-conversations) in the Admin API Reference.
|
|
6478
6858
|
|
|
6479
6859
|
```html
|
|
6480
6860
|
<!-- zero-code embed: keep the tag exactly this bare (no integrity/crossorigin) -->
|
|
@@ -6502,9 +6882,9 @@ bot?.destroy(); // optional teardown
|
|
|
6502
6882
|
|
|
6503
6883
|
The widget persists an anonymous session in `localStorage`, restores conversations on revisit, streams answers, and shows product recommendation cards (image, price, add-to-cart / view buttons). Multi-variant products get an **in-card variant picker** (attribute chips, live variant price/image) so shoppers can choose options and add without leaving the chat; shoppers can also just ask the assistant to add an item ("add the moka to my cart") and it happens through the same chain. A leave-a-message form lands in the merchant's Inquiries inbox, and zero-result searches feed the merchant's "unmet demand" analytics.
|
|
6504
6884
|
|
|
6505
|
-
**Add to cart resolution** (never a dead button): the widget first calls your `onAddToCart` option; without one it dispatches a cancelable `brainerce:bot:add-to-cart` `CustomEvent` on `window` (`detail: { productId, variantId, quantity, connectionId }
|
|
6885
|
+
**Add to cart resolution** (never a dead button): the widget first calls your `onAddToCart` option; without one it dispatches a cancelable `brainerce:bot:add-to-cart` `CustomEvent` on `window` (`detail: { productId, variantId, quantity, connectionId }`; call `preventDefault()` after handling it); if nothing handles either, it navigates to the product page. Products too complex for in-chat picking (3+ attribute dimensions or 25+ variants) always navigate. Aside from your own cart handler, the widget is read-only by design, and shoppers can never mutate the store through it.
|
|
6506
6886
|
|
|
6507
|
-
**Where the bot is allowed to load.** Every widget call
|
|
6887
|
+
**Where the bot is allowed to load.** Every widget call (bootstrap, chat, escalation) is validated against the page's `Origin` and the domain configured on the connection, the same rule the rest of the storefront API uses. A **Live** connection accepts only its configured domain (exact host or a subdomain) plus any additional allowed origins it lists; a **Test** connection with no domain accepts any origin, which is what makes `localhost` and preview URLs work; a Test connection _with_ a domain behaves like Live. A blocked origin is **not** an error: the bot simply does not render, indistinguishable from "switched off", so nobody can probe which connection ids exist. Mount client-side: server-rendered calls carry no `Origin` and a Live connection refuses them.
|
|
6508
6888
|
|
|
6509
6889
|
Merchant-side display controls (Studio → Storefront Bot): chat size (compact / full screen / shopper's choice), auto-open, position, and whether shoppers may expand the window (`allowExpand`).
|
|
6510
6890
|
|
|
@@ -6553,29 +6933,29 @@ export async function POST(req: Request) {
|
|
|
6553
6933
|
backend validates the `events` array on create against exactly this list, so
|
|
6554
6934
|
anything outside it is rejected rather than silently accepted.
|
|
6555
6935
|
|
|
6556
|
-
| Event | Description
|
|
6557
|
-
| --------------------- |
|
|
6558
|
-
| `order.created` | New order placed (any payment status)
|
|
6559
|
-
| `order.updated` | Order metadata changed (status, address, items)
|
|
6560
|
-
| `order.paid` | Order is paid
|
|
6561
|
-
| `order.fulfilled` | All items marked shipped/delivered
|
|
6562
|
-
| `order.cancelled` | Order cancelled (by merchant or customer)
|
|
6563
|
-
| `order.refunded` | Order fully or partially refunded
|
|
6564
|
-
| `customer.created` | New customer account created
|
|
6565
|
-
| `customer.updated` | Customer profile or contact details changed
|
|
6566
|
-
| `customer.deleted` | Customer account deleted
|
|
6567
|
-
| `product.created` | New product added to catalog
|
|
6568
|
-
| `product.updated` | Product attributes, variants, or pricing changed
|
|
6569
|
-
| `product.deleted` | Product removed from catalog
|
|
6570
|
-
| `inventory.updated` | Stock level changed (any reason)
|
|
6571
|
-
| `inventory.low` | Stock fell below the low-stock threshold
|
|
6572
|
-
| `checkout.completed` | Checkout completed (synonym of `order.created` for now)
|
|
6573
|
-
| `checkout.abandoned` | Cart inactive for 1+ hours with no completion
|
|
6574
|
-
| `payment.succeeded` | Payment provider confirmed funds captured
|
|
6575
|
-
| `payment.failed` | Payment provider rejected the transaction
|
|
6576
|
-
| `payment.refunded` | Refund posted to the customer
|
|
6577
|
-
| `blog.post.published` | Post went live (manual, scheduled, or SEO Autopilot)
|
|
6578
|
-
| `blog.post.updated` | Published post content changed
|
|
6936
|
+
| Event | Description |
|
|
6937
|
+
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
6938
|
+
| `order.created` | New order placed (any payment status) |
|
|
6939
|
+
| `order.updated` | Order metadata changed (status, address, items) |
|
|
6940
|
+
| `order.paid` | Order is paid, by provider capture **or** a merchant-recorded out-of-band payment (cash on delivery, bank transfer). Never assume a provider was involved; `payment.succeeded` does **not** fire for these |
|
|
6941
|
+
| `order.fulfilled` | All items marked shipped/delivered |
|
|
6942
|
+
| `order.cancelled` | Order cancelled (by merchant or customer) |
|
|
6943
|
+
| `order.refunded` | Order fully or partially refunded |
|
|
6944
|
+
| `customer.created` | New customer account created |
|
|
6945
|
+
| `customer.updated` | Customer profile or contact details changed |
|
|
6946
|
+
| `customer.deleted` | Customer account deleted |
|
|
6947
|
+
| `product.created` | New product added to catalog |
|
|
6948
|
+
| `product.updated` | Product attributes, variants, or pricing changed |
|
|
6949
|
+
| `product.deleted` | Product removed from catalog |
|
|
6950
|
+
| `inventory.updated` | Stock level changed (any reason) |
|
|
6951
|
+
| `inventory.low` | Stock fell below the low-stock threshold |
|
|
6952
|
+
| `checkout.completed` | Checkout completed (synonym of `order.created` for now) |
|
|
6953
|
+
| `checkout.abandoned` | Cart inactive for 1+ hours with no completion |
|
|
6954
|
+
| `payment.succeeded` | Payment provider confirmed funds captured |
|
|
6955
|
+
| `payment.failed` | Payment provider rejected the transaction |
|
|
6956
|
+
| `payment.refunded` | Refund posted to the customer |
|
|
6957
|
+
| `blog.post.published` | Post went live (manual, scheduled, or SEO Autopilot) |
|
|
6958
|
+
| `blog.post.updated` | Published post content changed |
|
|
6579
6959
|
|
|
6580
6960
|
Payload shapes for each are in the
|
|
6581
6961
|
[Event Catalogue](https://brainerce.com/docs/webhooks/events).
|
|
@@ -6584,27 +6964,14 @@ Payload shapes for each are in the
|
|
|
6584
6964
|
just merchant-created customers, so a storefront that registers customers will
|
|
6585
6965
|
start seeing it.
|
|
6586
6966
|
|
|
6587
|
-
|
|
6588
|
-
|
|
6589
|
-
|
|
6590
|
-
|
|
6591
|
-
|
|
6592
|
-
|
|
6593
|
-
|
|
6594
|
-
|
|
6595
|
-
> `blog.post.published`, `blog.post.updated`. So
|
|
6596
|
-
> `isWebhookEventType(event, 'customer.created')` and a
|
|
6597
|
-
> `createWebhookHandler({ 'order.paid': … })` key **fail to compile**, even
|
|
6598
|
-
> though both deliver correctly at runtime. Cast the name
|
|
6599
|
-
> (`'customer.created' as WebhookEventType`) or read `event.event` as a
|
|
6600
|
-
> `string` and switch on it yourself. Do not conclude the event does not exist.
|
|
6601
|
-
>
|
|
6602
|
-
> **8 names in the type cannot be subscribed to at all:** `coupon.created`,
|
|
6603
|
-
> `coupon.updated`, `coupon.deleted`, `cart.created`, `cart.updated`,
|
|
6604
|
-
> `cart.abandoned`, `checkout.started`, `checkout.failed`. These compile
|
|
6605
|
-
> cleanly and then fail at subscription time. `cart.abandoned` in particular
|
|
6606
|
-
> was listed as a supported event here for a long time — use
|
|
6607
|
-
> `checkout.abandoned` instead.
|
|
6967
|
+
The `WebhookEventType` type matches this table exactly as of SDK 2.0.2 —
|
|
6968
|
+
`isWebhookEventType(event, 'customer.created')` and
|
|
6969
|
+
`createWebhookHandler({ 'order.paid': … })` both compile and match the
|
|
6970
|
+
subscribable set. (Previous SDK versions shipped a stale 15-entry type that
|
|
6971
|
+
was missing 14 real events and still listed 8 fake ones — `coupon.*`,
|
|
6972
|
+
`cart.*`, `checkout.started`, `checkout.failed` — that never existed as
|
|
6973
|
+
subscribable events. If you're on an older SDK version, upgrade rather than
|
|
6974
|
+
casting around the type.)
|
|
6608
6975
|
|
|
6609
6976
|
---
|
|
6610
6977
|
|