brainerce 2.0.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 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 it is not available in chat-only tools like Google AI Studio or ChatGPT.
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 choose the right one
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 never in browser code |
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" it changes every route the client calls. Passing none throws `BrainerceClient: either salesChannelId, apiKey, or storeId is required`.
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()` exactly one returns `true`. (`isVibeCodedMode()` is a deprecated alias of `isSalesChannelMode()`.)
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 `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.
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, logs a deprecation warning on every construction, and is removed in SDK 2.0.
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 they'll appear the moment the store owner enables them.
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(cartId)` | ✅ |
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.smartGetCart()`, `client.getSearchSuggestions(query)` | ✅ |
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 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.
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 fetch on demand.
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 clears the cart so users don't see stale items.
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 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.
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 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.
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 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">` do NOT add `flex-row-reverse` on top.
125
- - Call `setLocale()` **before `createCheckout()`** 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.
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** always `parseFloat()` before math or comparisons.
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 call `getCartTotals(cart)`.
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 its value lands on the order for the merchant.
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,13 +162,13 @@ 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` **not** `rate.name`, which for live carrier rates is the
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` `'sdk-widget'` (Stripe, PayPal, Grow), `'iframe'` (Cardcom), `'redirect'`, `'sandbox'`. Branch on `renderType`, never on provider name.
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
@@ -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 it auto-hides.
195
+ > Build the verify-email step even if verification is currently disabled; it auto-hides.
196
196
 
197
197
  ### Login flow
198
198
 
@@ -204,8 +204,8 @@ 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()` 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.
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
 
@@ -226,9 +226,10 @@ These sequences are non-negotiable. The order of SDK calls matters.
226
226
  ```
227
227
 
228
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
- 3. `const result = await client.waitForOrder(checkoutId)` — polls until the webhook writes the order. `result.status.orderNumber` / `result.status.orderId` are available on success.
230
- 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 the order WILL appear there.
231
- 5. On success: render the order number, or if your design wants more than that fetch full details:
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:
232
233
 
233
234
  ```typescript
234
235
  const result = await client.waitForOrder(checkoutId);
@@ -240,7 +241,7 @@ if (result.success) {
240
241
  }
241
242
  ```
242
243
 
243
- `getOrderByCheckout` works for guests too possession of the checkout id is
244
+ `getOrderByCheckout` works for guests too, because possession of the checkout id is
244
245
  the credential, no customer token needed.
245
246
 
246
247
  ### Password reset flow
@@ -274,7 +275,7 @@ the credential, no customer token needed.
274
275
  // then redirect to account
275
276
  }
276
277
  ```
277
- 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.
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.
278
279
  4. On failure the browser lands on **the same `redirectUrl`** (never on the API host), carrying `oauth_error` + `error_description`:
279
280
  ```ts
280
281
  const oauthError = params.get('oauth_error') as OAuthErrorCode | null;
@@ -288,16 +289,16 @@ the credential, no customer token needed.
288
289
  }
289
290
  }
290
291
  ```
291
- The code list is open the provider's own codes (`access_denied`, …) pass through, so always handle the default case.
292
+ The code list is open: the provider's own codes (`access_denied`, …) pass through, so always handle the default case.
292
293
 
293
294
  > Build the OAuth button region AND the callback handler even when no providers are configured.
294
295
 
295
296
  ### Inventory reservation flow
296
297
 
297
- - Display the countdown from `cart.reservation?.expiresAt` refresh once per second (`reservation` is optional; only present when a reservation strategy is active).
298
+ - Display the countdown from `cart.reservation?.expiresAt`, refreshing once per second (`reservation` is optional; only present when a reservation strategy is active).
298
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.
299
300
  - On the checkout page: if reservation has expired, block payment and show "your cart has expired" with a link back to cart.
300
- - Do NOT implement your own timer logic the SDK is the source of truth.
301
+ - Do NOT implement your own timer logic; the SDK is the source of truth.
301
302
 
302
303
  ---
303
304
 
@@ -305,33 +306,33 @@ the credential, no customer token needed.
305
306
 
306
307
  The SDK exports these utility functions for common UI tasks:
307
308
 
308
- | Function | Purpose | Example |
309
- | ---------------------------------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
310
- | `formatPrice(amount, { currency?, locale? })` | Format prices for display | `formatPrice("99.99", { currency: 'USD' })` → `$99.99` |
311
- | `getPriceDisplay(amount, currency?, locale?)` | Alias for `formatPrice` | Same as above |
312
- | `getDescriptionContent(product)` | Get product description (HTML or text) | `getDescriptionContent(product)` |
313
- | `isHtmlDescription(product)` | Check if description is HTML | `isHtmlDescription(product)` → `true/false` |
314
- | `getStockStatus(inventory)` | Get human-readable stock status | `getStockStatus(inventory)` → `"In Stock"` |
315
- | `getProductPrice(product)` | Get effective price (handles sales) | `getProductPrice(product)` → `29.99` |
316
- | `getProductPriceInfo(product)` | Get price + sale info + discount % (falls back to `priceMin` when `basePrice=0` on VARIABLE) | `{ price, isOnSale, discountPercent }` |
317
- | `getVariantPrice(variant, basePrice)` | Get variant price with fallback | `getVariantPrice(variant, '29.99')` → `34.99` |
318
- | `getCartTotals(cart, shippingPrice?)` | Calculate cart subtotal/discount/total | `{ subtotal, discount, shipping, total }` |
319
- | `getCartItemName(item)` | Get name from nested cart item (product + variant) | `getCartItemName(item)` → `"Blue T-Shirt - Large"` |
320
- | `getCartItemImage(item)` | Get image URL from cart item | `getCartItemImage(item)` → `"https://..."` |
321
- | `getVariantOptions(variant)` | Get variant attributes as array | `[{ name: "Color", value: "Red" }]` |
322
- | `isCouponApplicableToProduct(coupon, product)` | Check if coupon applies | `isCouponApplicableToProduct(coupon, product)` |
323
- | `isAllowedPaymentUrl(url, options?)` | Validate a payment URL host | `isAllowedPaymentUrl(intent.clientSecret)` → `true` |
324
- | `safePaymentRedirect(url, options?)` | Validate then `window.location.href` | `safePaymentRedirect(intent.clientSecret)` |
325
- | `buildProductJsonLd(product, opts)` | schema.org Product JSON-LD (PDPs only) | See SEO section |
326
- | `buildArticleJsonLd(post, opts)` | schema.org Article JSON-LD for blog posts | See SEO section |
327
- | `buildOrganizationJsonLd(store, opts)` | schema.org Organization for the homepage | See SEO section |
328
- | `buildBreadcrumbJsonLd(items)` | schema.org BreadcrumbList | See SEO section |
329
- | `buildProductFaqJsonLd(product)` | schema.org FAQPage from `product.faq` (null when empty) render the same pairs as visible text | `const faq = buildProductFaqJsonLd(product)` |
330
- | `jsonLdScriptProps(data)` | XSS-safe `<script type="application/ld+json">` props | `<script {...jsonLdScriptProps(data)} />` |
331
- | `getBlogSitemapEntries(client, opts)` | Paginate published posts into sitemap entries | See SEO section |
332
- | `getProductSitemapEntries(client, opts)` | ALL published products into sitemap entries (no 100-item clamp) | See SEO section |
333
- | `getCategorySitemapEntries(client, opts)` | Category tree into sitemap entries | See SEO section |
334
- | `client.resolveSlugRedirect(type, slug)` | Renamed slug → current slug (301 support in not-found paths) | See SEO section |
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 |
335
336
 
336
337
  ```typescript
337
338
  import {
@@ -414,7 +415,7 @@ const { data: products } = await client.getProducts();
414
415
 
415
416
  ### Product customization fields (buyer input)
416
417
 
417
- Products can expose `customizationFields` 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 no client-side merging required.
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.
418
419
 
419
420
  ```typescript
420
421
  if (product.customizationFields?.length) {
@@ -441,7 +442,7 @@ Full rendering guide + per-type validation rules: [Core Integration §2.8](https
441
442
 
442
443
  ### Modifier groups (restaurant / build-your-own products)
443
444
 
444
- Products can expose `modifierGroups` 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.
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.
445
446
 
446
447
  ```typescript
447
448
  // 5-line add-to-cart with modifiers
@@ -455,7 +456,7 @@ await client.addToCart(cart.id, {
455
456
  });
456
457
  ```
457
458
 
458
- 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.
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.
459
460
 
460
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).
461
462
 
@@ -479,22 +480,22 @@ const page = await client.content.page.getBySlug(params.slug, locale);
479
480
  if (!page) notFound();
480
481
  ```
481
482
 
482
- All `get` / `getBySlug` return `null` on 404 render a hard-coded fallback so the page never crashes when the merchant hasn't seeded yet.
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.
483
484
 
484
- **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 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:
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:
485
486
 
486
487
  ```typescript
487
488
  import DOMPurify from 'isomorphic-dompurify';
488
489
  <div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(rawHtml) }} />
489
490
  ```
490
491
 
491
- 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.
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.
492
493
 
493
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).
494
495
 
495
496
  ### Blog
496
497
 
497
- 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.
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.
498
499
 
499
500
  ```typescript
500
501
  // List published posts (any SDK mode)
@@ -519,11 +520,11 @@ return <div dangerouslySetInnerHTML={{ __html: safeHtml }} className="prose" />;
519
520
 
520
521
  **Scheduling**: A post is visible once `status === 'PUBLISHED'` and `publishedAt <= now()`. Set a future `publishedAt` when publishing to schedule.
521
522
 
522
- **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.
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.
523
524
 
524
- ### SEO JSON-LD builders, sitemap helpers, IndexNow key, llms.txt + agents.md
525
+ ### SEO: JSON-LD builders, sitemap helpers, IndexNow key, llms.txt + agents.md
525
526
 
526
- 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` 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, never fabricated, if you don't pass it). Prefer these builders over hand-rolled JSON-LD:
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:
527
528
 
528
529
  ```tsx
529
530
  import {
@@ -553,7 +554,7 @@ import {
553
554
  }))} />
554
555
  ```
555
556
 
556
- **Product + category + blog entries in sitemap.xml** (required). ⚠️ Products **must** use `getProductSitemapEntries` 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:
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:
557
558
 
558
559
  ```ts
559
560
  // app/sitemap.ts
@@ -581,15 +582,15 @@ const blogPages = await getBlogSitemapEntries(client, {
581
582
  return [...staticPages, ...productPages, ...categoryPages, ...blogPages];
582
583
  ```
583
584
 
584
- **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.
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.
585
586
 
586
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).
587
588
 
588
- **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 locale middleware matchers skip dotted paths, so a locale-nested copy serves the homepage HTML instead.
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.
589
590
 
590
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).
591
592
 
592
- **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.
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.
593
594
 
594
595
  ---
595
596
 
@@ -784,7 +785,7 @@ const address: SetShippingAddressDto = {
784
785
  ```
785
786
 
786
787
  **And no coordinates.** The address endpoints validate against a strict
787
- allow-list one property that isn't on the DTO rejects the whole call with
788
+ allow-list: one property that isn't on the DTO rejects the whole call with
788
789
  `400 "property lat should not exist"`, which doesn't degrade: it blocks the
789
790
  address step for every shopper. `getAddressDetails()` resolves an address
790
791
  carrying `lat`, `lng` and `formattedAddress`, so this is the one that bites:
@@ -810,8 +811,8 @@ await client.setShippingAddress(checkoutId, {
810
811
  });
811
812
  ```
812
813
 
813
- Use `address.lat` / `address.lng` for your own UI a map pin, a distance
814
- readout and nothing else.
814
+ Use `address.lat` / `address.lng` for your own UI (a map pin, a distance
815
+ readout) and nothing else.
815
816
 
816
817
  ### 10. OAuth - Use `authorizationUrl`, NOT `url`
817
818
 
@@ -890,7 +891,7 @@ const total = subtotal - discount;
890
891
  - Cart field is `discountAmount`, NOT `discount`
891
892
  - Cart has NO `total` field - use `getCartTotals()` or calculate
892
893
  - Checkout DOES have a `total` field, but Cart does not
893
- - `getCartTotals()` works with all carts guests now use server-side session carts with full pricing fields.
894
+ - `getCartTotals()` works with all carts; guests now use server-side session carts with full pricing fields.
894
895
 
895
896
  ### 15. SearchSuggestions - Products Have `price`, Not `basePrice`
896
897
 
@@ -979,9 +980,9 @@ const totals = getCartTotals(cart); // { subtotal, discount, shipping, total }
979
980
 
980
981
  ### Detecting a Silent Session Cart Reset
981
982
 
982
- If a guest's stored session cart can no longer be resolved as-is the fetch
983
- fails, or the cart was found but is no longer `ACTIVE` (e.g. a prior checkout
984
- on it already completed) `smartAddToCart()` / `smartGetCart()` /
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()` /
985
986
  `smartUpdateCartItem()` transparently start a fresh empty cart so the call
986
987
  still succeeds. Pass `onCartReset` to the constructor to find out when this
987
988
  happens, so you can tell the shopper their cart expired instead of them just
@@ -997,7 +998,7 @@ const client = new BrainerceClient({
997
998
  });
998
999
  ```
999
1000
 
1000
- ### On Login Merge Guest Cart
1001
+ ### On Login: Merge Guest Cart
1001
1002
 
1002
1003
  ```typescript
1003
1004
  // After setting customer token
@@ -1069,17 +1070,17 @@ console.log('Order created:', orderId);
1069
1070
 
1070
1071
  Turn the shipping address's `line1` input into a typeahead instead of free
1071
1072
  text. Suggestions come from Google Places; each resolved address is flagged
1072
- `inZone` against the store's configured shipping zones a soft signal for a
1073
- warning banner, never a hard block.
1073
+ `inZone` against the store's configured shipping zones, a soft signal for a
1074
+ warning banner and never a hard block.
1074
1075
 
1075
1076
  Suggestions are limited to deliverable address types (street addresses, routes,
1076
1077
  buildings, sub-premises). Businesses, stations and other establishments are
1077
- never returned a courier cannot deliver to one. A shopper who types only a
1078
+ never returned, because a courier cannot deliver to one. A shopper who types only a
1078
1079
  landmark name gets an empty list and has to type the street.
1079
1080
 
1080
1081
  `inZone` resolves a zone's currency-region restriction the same way the checkout
1081
- does destination country first, then the `regionId` you pass, then the store's
1082
- default region so a `true` here is not contradicted by the rates you fetch
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
1083
1084
  afterwards.
1084
1085
 
1085
1086
  ```typescript
@@ -1174,7 +1175,7 @@ await client.smartUpdateCartItem('prod_123', 5);
1174
1175
  await client.smartRemoveFromCart('prod_123');
1175
1176
  ```
1176
1177
 
1177
- ### After Login Sync Cart
1178
+ ### After Login: Sync Cart
1178
1179
 
1179
1180
  ```typescript
1180
1181
  client.setCustomerToken(token);
@@ -1182,14 +1183,14 @@ const mergedCart = await client.syncCartOnLogin();
1182
1183
  // Guest session cart items are merged into the customer's server cart
1183
1184
  ```
1184
1185
 
1185
- ### After Checkout Clear Cart
1186
+ ### After Checkout: Clear Cart
1186
1187
 
1187
1188
  ```typescript
1188
1189
  client.onCheckoutComplete();
1189
1190
  // Clears session cart reference so next visit starts fresh
1190
1191
  ```
1191
1192
 
1192
- ### After Logout Preserve Guest Cart
1193
+ ### After Logout: Preserve Guest Cart
1193
1194
 
1194
1195
  ```typescript
1195
1196
  client.clearCustomerToken();
@@ -1390,7 +1391,7 @@ interface CategoryNode {
1390
1391
 
1391
1392
  #### Get Category by Slug (Category Page)
1392
1393
 
1393
- Category (collection) pages are the highest-leverage organic-SEO surface 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] })`.
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] })`.
1394
1395
 
1395
1396
  ```typescript
1396
1397
  // app/category/[slug]/page.tsx
@@ -1567,7 +1568,7 @@ function ProductFilters() {
1567
1568
 
1568
1569
  **Key points for AI builders:**
1569
1570
 
1570
- - `getCategories()` returns a **tree** don't flatten it! Use `children` to build nested UI.
1571
+ - `getCategories()` returns a **tree**; don't flatten it! Use `children` to build nested UI.
1571
1572
  - Selecting a parent category automatically includes all descendants (backend handles this).
1572
1573
  - Use `position: relative` on the chip wrapper and `position: absolute` on the dropdown for proper overlay positioning.
1573
1574
  - Use `paddingInlineStart` (not `paddingLeft`) for RTL support.
@@ -1709,7 +1710,7 @@ function SearchInput() {
1709
1710
 
1710
1711
  #### Product Type Definition
1711
1712
 
1712
- > **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
1713
1714
  > import the real types (`import type { Product, ProductVariant } from 'brainerce'`)
1714
1715
  > rather than retyping them. See the Critical Rule: _never write your own copies
1715
1716
  > of SDK types._
@@ -1808,14 +1809,14 @@ interface InventoryInfo {
1808
1809
 
1809
1810
  > **Variant prices are strings.** `variant.price` and `variant.salePrice` are
1810
1811
  > `string | null`, exactly like `product.basePrice`. `variant.price > 100` compares
1811
- > lexicographically and silently returns the wrong answer always `parseFloat()`
1812
+ > lexicographically and silently returns the wrong answer, so always `parseFloat()`
1812
1813
  > first, or use `getVariantPrice(variant)` / `formatVariantPrice(variant)`.
1813
1814
 
1814
1815
  #### Product Metafields (Custom Fields)
1815
1816
 
1816
1817
  Products can have custom fields (metafields) defined by the store owner, such as "Material", "Care Instructions", or "Warranty".
1817
1818
 
1818
- **Important:** Each metafield has a `type` field. When rendering, you **must** check `field.type` and render accordingly don't just display `field.value` as text for all types.
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.
1819
1820
 
1820
1821
  | Type | Rendering |
1821
1822
  | ----------------------------------------------------------- | ------------------------------------------------------- |
@@ -1887,7 +1888,7 @@ definitions.forEach((def) => {
1887
1888
  **Faceted filtering with product counts.** Definitions the merchant marked
1888
1889
  `filterable: true` (types `SELECT` / `MULTI_SELECT` / `BOOLEAN`) can be
1889
1890
  rendered as storefront facets. `getMetafieldFilters()` returns each of them
1890
- with per-value counts of distinct active products so you can show
1891
+ with per-value counts of distinct active products, so you can show
1891
1892
  "Color: red (12) / blue (3)" without one `getProducts` call per value:
1892
1893
 
1893
1894
  ```typescript
@@ -1959,9 +1960,9 @@ await client.addToCart(cartId, {
1959
1960
  });
1960
1961
  ```
1961
1962
 
1962
- **Display customizations in cart/checkout no extra API call needed:**
1963
+ **Display customizations in cart/checkout, with no extra API call needed:**
1963
1964
 
1964
- `CartItem` and `CheckoutLineItem` both include a `customizations` object with resolved labels. Use it directly no need to call `getProduct()` per item.
1965
+ `CartItem` and `CheckoutLineItem` both include a `customizations` object with resolved labels. Use it directly; no need to call `getProduct()` per item.
1965
1966
 
1966
1967
  ```typescript
1967
1968
  // Works for CartItem, CheckoutLineItem, and OrderItem — same shape
@@ -2002,7 +2003,7 @@ await client.addToCart(cartId, {
2002
2003
  | GALLERY | `string[]` (URLs) | Multi-file upload |
2003
2004
  | DIMENSION/WEIGHT | `{ value, unit }` | Value + unit inputs |
2004
2005
 
2005
- **Assigning fields to a product is dashboard-only there is no SDK path to it.**
2006
+ **Assigning fields to a product is dashboard-only; there is no SDK path to it.**
2006
2007
  `client.setProductCustomizationFields()` and `client.getProductCustomizationFields()`
2007
2008
  target `/api/v1/metafield-definitions/products/:productId/customization-fields`, which
2008
2009
  the public API does not expose; both return **404**. Those routes exist only on the
@@ -2011,8 +2012,8 @@ behind Clerk auth. Choose which customer-input definitions apply to a product in
2011
2012
  dashboard.
2012
2013
 
2013
2014
  > **Two different things share the name `getProductCustomizationFields`.** The
2014
- > **exported helper** used above `import { getProductCustomizationFields } from 'brainerce'`
2015
- > is a pure function that reads the definitions off a product you already fetched. It
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
2016
2017
  > works in every mode and is the one you want. The **client method** of the same name,
2017
2018
  > which writes the assignment, is the one that 404s. Reading is fully covered without it:
2018
2019
  > `product.customizationFields` is already on every product response.
@@ -2179,7 +2180,8 @@ Requires a customer token. Call it before rendering any review UI: it answers
2179
2180
  all four cases in one request, sign in, not eligible, submit, edit.
2180
2181
 
2181
2182
  ```typescript
2182
- const { eligible, reason, myReview, photos, myImages } = await client.getMyProductReview('prod_123');
2183
+ const { eligible, reason, myReview, photos, myImages } =
2184
+ await client.getMyProductReview('prod_123');
2183
2185
 
2184
2186
  if (!eligible) {
2185
2187
  // reason: 'no_eligible_order' | 'reviews_disabled' | 'product_not_found' | null
@@ -2372,7 +2374,7 @@ console.log(updated.couponCode); // "SAVE20"
2372
2374
  await client.removeCoupon(cart.id);
2373
2375
  ```
2374
2376
 
2375
- **On the checkout page** (checkout session already exists preferred):
2377
+ **On the checkout page** (checkout session already exists, preferred):
2376
2378
 
2377
2379
  ```typescript
2378
2380
  // applyCheckoutCoupon applies to cart AND updates checkout totals atomically
@@ -2383,7 +2385,7 @@ console.log(checkout.total); // updated total
2383
2385
  await client.removeCheckoutCoupon(checkoutId);
2384
2386
  ```
2385
2387
 
2386
- > **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.
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.
2387
2389
 
2388
2390
  #### Cart Totals
2389
2391
 
@@ -2397,12 +2399,12 @@ const totals = getCartTotals(cart);
2397
2399
 
2398
2400
  ---
2399
2401
 
2400
- ### Guest Checkout (Submit Order) no payment collected
2402
+ ### Guest Checkout (Submit Order): no payment collected
2401
2403
 
2402
2404
  > **⛔ Not for stores that take payment.** `submitGuestOrder()` posts the order
2403
2405
  > straight to `POST /orders`. It never creates a payment intent, so the order is
2404
2406
  > created **unpaid** and no card is ever charged. Use it only where checkout
2405
- > collects no money cash on delivery, manual invoicing, or a sandbox store.
2407
+ > collects no money: cash on delivery, manual invoicing, or a sandbox store.
2406
2408
  >
2407
2409
  > For every other store use `startGuestCheckout()`, which creates a real checkout
2408
2410
  > session from the session cart and hands you a `checkoutId` to run the payment
@@ -2552,7 +2554,7 @@ const cart = await client.createCart();
2552
2554
  > **GA4 server-side conversions:** if you called `client.loadGoogleAnalytics('G-XXXXXXX')`
2553
2555
  > once at app startup (see [Analytics](#analytics-optional-server-side-ga4-conversions)
2554
2556
  > below), the resolved `client_id`/`session_id` are auto-attached to `createCart`,
2555
- > `addToCart`, `setCheckoutCustomer`, and `setShippingAddress` automatically no
2557
+ > `addToCart`, `setCheckoutCustomer`, and `setShippingAddress` automatically, with no
2556
2558
  > other code changes needed. Pass `analyticsClientId`/`analyticsSessionId`
2557
2559
  > explicitly on any of these calls to override.
2558
2560
 
@@ -2687,17 +2689,17 @@ const checkout = await client.createCheckout({
2687
2689
 
2688
2690
  > The region is recorded on the checkout and used for payment-provider scoping.
2689
2691
  > **FX-at-checkout:** when the region currency differs from the store base and its
2690
- > provider can settle it (presentment-enabled Stripe today), the buyer is charged
2692
+ > provider can settle it (presentment-enabled, Stripe today), the buyer is charged
2691
2693
  > in the region currency and the checkout response carries a `presentment` overlay
2692
2694
  > (`presentment.total` / `presentment.currency` = what's charged). Otherwise the
2693
2695
  > checkout is charged in the store base currency.
2694
2696
 
2695
2697
  #### Region display pricing (`getProducts({ regionId })`)
2696
2698
 
2697
- All three product reads accept an optional `regionId` `getProducts`,
2699
+ All three product reads accept an optional `regionId`: `getProducts`,
2698
2700
  `getProduct(id)`, **and** `getProductBySlug(slug)` (the PDP path). When set and the
2699
2701
  region's currency differs from the store currency, each product/variant gains
2700
- additive FX-display fields `displayPrice`, `displaySalePrice`, and
2702
+ additive FX-display fields: `displayPrice`, `displaySalePrice`, and
2701
2703
  `displayCurrency` (plus `displayPriceMin` / `displayPriceMax` on storefront-mode
2702
2704
  list reads). Your `basePrice` / `salePrice` stay in the store currency; the
2703
2705
  display fields appear **only** when an FX rate applies, so an omitted/invalid
@@ -2716,11 +2718,11 @@ await client.getProductBySlug('blue-shirt', { regionId: 'region_eu' });
2716
2718
  ```
2717
2719
 
2718
2720
  > **Display-only, and unlike checkout.** `regionId` on a _product read_ never
2719
- > affects what is charged it only changes what you render. `regionId` on
2721
+ > affects what is charged; it only changes what you render. `regionId` on
2720
2722
  > `createCheckout()` is different: it CAN charge the region currency (see
2721
2723
  > FX-at-checkout above). Do not carry the "display-only" assumption from here into
2722
2724
  > the checkout step. Works in
2723
- > **all three modes** vibe-coded (`vc_*`/`salesChannelId`), storefront (`storeId`),
2725
+ > **all three modes**: vibe-coded (`vc_*`/`salesChannelId`), storefront (`storeId`),
2724
2726
  > and admin (`apiKey`). The response gains `displayPrice` whenever a daily FX rate
2725
2727
  > exists for the store/region currency pair in **either** direction (the overlay
2726
2728
  > inverts the stored rate when needed). See [Regions](/docs/concepts/regions).
@@ -2801,13 +2803,13 @@ console.log(rates); // ShippingRate[]
2801
2803
  > re-resolves it to the address's exact coordinates, which is how stores that
2802
2804
  > draw their delivery areas on a map ("polygon" zones) decide whether they
2803
2805
  > cover the shopper. Without it the server falls back to geocoding the typed
2804
- > address text, which is materially less precise a same-named street in a
2806
+ > address text, which is materially less precise: a same-named street in a
2805
2807
  > neighbouring city can outrank the right one, quoting the shopper another
2806
2808
  > area's rate or no delivery at all. **Clear `placeId` if the shopper edits any
2807
- > address field after picking** its coordinates describe the suggestion, not
2809
+ > address field after picking**, because its coordinates describe the suggestion, not
2808
2810
  > the edited text. There is deliberately no `lat`/`lng` field: zone matching
2809
2811
  > decides which rate is charged, so coordinates are never accepted from the
2810
- > client the server resolves them from `placeId` itself.
2812
+ > client. The server resolves them from `placeId` itself.
2811
2813
  >
2812
2814
  > The endpoint rejects **any** unknown property with a `400` ("property lat
2813
2815
  > should not exist"), which blocks checkout entirely rather than degrading. So
@@ -2822,7 +2824,7 @@ console.log(rates); // ShippingRate[]
2822
2824
  > same area, put several rates on one zone rather than several zones.
2823
2825
 
2824
2826
  > **Live carrier rates:** render `rate.speedTier` (`'cheapest' | 'balanced' | 'fastest'`)
2825
- > and `rate.estimatedDays`, not `rate.name` `name` is the carrier's own service
2827
+ > and `rate.estimatedDays`, not `rate.name`, because `name` is the carrier's own service
2826
2828
  > identifier and means nothing to a shopper. Rates arrive already narrowed to at most
2827
2829
  > three. Manual zone rates carry no `speedTier`; show their `name` as the merchant wrote
2828
2830
  > it. Full snippet under [Checkout Type Definition](#checkout-type-definition).
@@ -2830,7 +2832,7 @@ console.log(rates); // ShippingRate[]
2830
2832
  > **Order notes:** every checkout page should render an optional **"Order
2831
2833
  > notes"** textarea by default. Send its value via `notes` on either
2832
2834
  > `setCheckoutCustomer` or `setShippingAddress` (whichever call your flow
2833
- > makes last) max 2000 chars, empty string clears a previously-set note.
2835
+ > makes last). Max 2000 chars, and an empty string clears a previously-set note.
2834
2836
  > The note is copied onto the order at completion (merchant sees it in the
2835
2837
  > dashboard, it's included in the confirmation email) and echoed back
2836
2838
  > read-only as `Order.notes` on buyer order responses.
@@ -2890,10 +2892,10 @@ const updatedCheckout = await client.setCheckoutCustomFields(checkoutId, {
2890
2892
 
2891
2893
  A `DATE`/`DATETIME` field's `dateAvailability` (blocked weekdays, blocked specific
2892
2894
  dates, min/max date range, the relative bounds `leadTimeMinutes` / `cutoffTime` /
2893
- `maxDaysAhead`, and for `DATETIME` business hours + time
2895
+ `maxDaysAhead`, plus (for `DATETIME`) business hours and time
2894
2896
  slots) is a merchant-configured restriction on which values the customer may
2895
2897
  pick. Use `computeAvailableSlots()` / `getBusinessHoursForDate()` /
2896
- `isDateValueAllowed()` to drive your own date-picker/slot-picker UI the SDK
2898
+ `isDateValueAllowed()` to drive your own date-picker/slot-picker UI. The SDK
2897
2899
  ships no calendar component, only the math (evaluated in the **store's**
2898
2900
  timezone, never the browser's):
2899
2901
 
@@ -2938,10 +2940,10 @@ if (slots.length) {
2938
2940
  ```
2939
2941
 
2940
2942
  **Submitting the value.** `DATE` is `"YYYY-MM-DD"`. `DATETIME` is one ISO-8601
2941
- value `"2026-08-15T09:30:00+03:00"`, or `"2026-08-15T09:30"` to mean the
2943
+ value: `"2026-08-15T09:30:00+03:00"`, or `"2026-08-15T09:30"` to mean the
2942
2944
  store's own timezone (the safest choice: a buyer travelling abroad would
2943
2945
  otherwise book their local hour). Fractional seconds are optional and may carry
2944
- 19 digits, so `Instant.toString()` / `datetime.isoformat()` output from a
2946
+ 1 to 9 digits, so `Instant.toString()` / `datetime.isoformat()` output from a
2945
2947
  non-JS backend is accepted as-is. **Never build it by concatenating a slot
2946
2948
  label**: `` `${date}T13:00-14:00` `` is rejected with HTTP 400 because
2947
2949
  `-14:00` parses as a UTC offset, not a time range.
@@ -2964,7 +2966,7 @@ concrete dates they currently mean, which is what to show a shopper who asked
2964
2966
  for something too soon.
2965
2967
 
2966
2968
  The backend independently re-validates every submitted value against the same
2967
- constraints at write time this is a client-side UX aid, not the source of
2969
+ constraints at write time. This is a client-side UX aid, not the source of
2968
2970
  enforcement.
2969
2971
 
2970
2972
  **Pricing types:**
@@ -3016,8 +3018,8 @@ interface ShippingRate {
3016
3018
  ```
3017
3019
 
3018
3020
  **Render `speedTier` and `estimatedDays`, not `name`, for live carrier rates.**
3019
- `name` carries the carrier's own service identifier `USPS PriorityMailInternational`,
3020
- `USAExportPBA USAExportStandard` which answers a question no shopper asked. They are
3021
+ `name` carries the carrier's own service identifier (`USPS PriorityMailInternational`,
3022
+ `USAExportPBA USAExportStandard`), which answers a question no shopper asked. They are
3021
3023
  choosing between _how fast_ and _how much_. Label the tiers in your own words and locale:
3022
3024
 
3023
3025
  ```typescript
@@ -3167,12 +3169,12 @@ const checkoutId = checkout.id;
3167
3169
  Use this method to get ALL enabled payment providers and build dynamic UI.
3168
3170
 
3169
3171
  **Primary vs. additive methods (Shopify-parity).** Each provider carries a
3170
- `methodType`. A `CREDIT_CARD` provider is the _primary_ card processor the
3172
+ `methodType`. A `CREDIT_CARD` provider is the _primary_ card processor, the
3171
3173
  single method that settles the order (`defaultProvider`, `isAdditive: false`,
3172
3174
  `presentation: 'card_form'`). Everything else is _additive_ (`isAdditive: true`),
3173
3175
  e.g. PayPal is a `'WALLET'` with `presentation: 'express_button'`. Render additive
3174
- methods as accelerated-checkout **express buttons above the card form** they sit
3175
- _alongside_ the primary, never replace it. (Exception: a wallet-only store has no
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
3176
3178
  card processor, so its wallet becomes the `defaultProvider` and stands alone.)
3177
3179
 
3178
3180
  ```typescript
@@ -3235,7 +3237,7 @@ if (paypalProvider) {
3235
3237
  }
3236
3238
  ```
3237
3239
 
3238
- #### Get Payment Configuration (Single Provider) DEPRECATED
3240
+ #### Get Payment Configuration (Single Provider): DEPRECATED
3239
3241
 
3240
3242
  > **`getPaymentConfig()` is `@deprecated`.** It only ever describes one provider, so
3241
3243
  > a store with an additive express method (PayPal, a wallet) renders wrong. Use
@@ -3284,13 +3286,13 @@ both are omitted from most copy-paste snippets. `clientSdk.renderType` is one of
3284
3286
 
3285
3287
  | `renderType` | What `clientSecret` holds | What to do |
3286
3288
  | -------------- | ------------------------- | ------------------------------------------------------------------------------------------------ |
3287
- | `'sandbox'` | (unused) | No payment UI complete the checkout directly |
3289
+ | `'sandbox'` | (unused) | No payment UI; complete the checkout directly |
3288
3290
  | `'sdk-widget'` | Client secret / auth code | Load `clientSdk.scriptUrl`, init with `clientSdk.initConfig`, mount into `clientSdk.containerId` |
3289
3291
  | `'iframe'` | **A URL** | Load it in an iframe (inline if its path contains `/embed/`, else in a modal) |
3290
3292
  | `'redirect'` | **A URL** | Navigate the top-level window to it; on return call `confirmSdkPayment()` |
3291
3293
 
3292
- Branch on `clientSdk?.renderType`. **Never** branch on "does `clientSdk` exist"
3293
- every provider returns one, sandbox included and never hard-code by provider name.
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.
3294
3296
  Only `provider === 'stripe'` has a `clientSdk.initConfig.publishableKey`.
3295
3297
 
3296
3298
  #### Confirm an SDK / redirect payment
@@ -3306,14 +3308,14 @@ await client.confirmSdkPayment(checkoutId, { transactionId: 'txn_123' });
3306
3308
 
3307
3309
  Call it in two places:
3308
3310
 
3309
- - **In an in-page SDK's success callback** (`renderType: 'sdk-widget'`) it tells
3311
+ - **In an in-page SDK's success callback** (`renderType: 'sdk-widget'`), where it tells
3310
3312
  the backend the payment succeeded, which triggers order creation.
3311
- - **On the return page from a `renderType: 'redirect'` provider** redirect
3313
+ - **On the return page from a `renderType: 'redirect'` provider**, because redirect
3312
3314
  providers don't capture until the server confirms, so this is what makes the
3313
3315
  backend verify with the provider and capture.
3314
3316
 
3315
3317
  It is **idempotent** (safe if a webhook already captured) and safe to skip on
3316
- failure `getPaymentStatus()` / `waitForOrder()` re-verify server-side. Wrap it in
3318
+ failure, since `getPaymentStatus()` / `waitForOrder()` re-verify server-side. Wrap it in
3317
3319
  `try/catch` and carry on:
3318
3320
 
3319
3321
  ```typescript
@@ -3325,13 +3327,13 @@ try {
3325
3327
  const result = await client.waitForOrder(checkoutId);
3326
3328
  ```
3327
3329
 
3328
- Do **not** call it on your `cancelUrl` the buyer abandoned; just let them retry.
3330
+ Do **not** call it on your `cancelUrl`. The buyer abandoned; just let them retry.
3329
3331
 
3330
3332
  `confirmGrowPayment()` is a deprecated wrapper around this method; call
3331
3333
  `confirmSdkPayment()` directly.
3332
3334
 
3333
3335
  > `confirmSdkPayment()`, `getPaymentStatus()`, `createPaymentIntent()`,
3334
- > `getPaymentProviders()` and `waitForOrder()` are **sales-channel mode only** —
3336
+ > `getPaymentProviders()` and `waitForOrder()` are **sales-channel mode only**;
3335
3337
  > they throw `BrainerceError` 400 on a `storeId` or `apiKey` client.
3336
3338
 
3337
3339
  **Routing to a specific provider (`providerId`).** With `getPaymentProviders()` you
@@ -3604,11 +3606,11 @@ function PaymentForm({ checkoutId }: { checkoutId: string }) {
3604
3606
 
3605
3607
  #### Complete Order After Payment: `completeGuestCheckout()` (legacy untracked flow only)
3606
3608
 
3607
- > **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).
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).
3608
3610
 
3609
3611
  **CRITICAL (untracked flow only):** After payment succeeds, you MUST call `completeGuestCheckout()` to create the order on the server.
3610
3612
 
3611
- > **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.
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.
3612
3614
 
3613
3615
  ```typescript
3614
3616
  // On your /checkout/success page:
@@ -3696,7 +3698,7 @@ if (intent.clientSdk?.renderType === 'sandbox') {
3696
3698
 
3697
3699
  #### Register Customer
3698
3700
 
3699
- > **Password policy enforced on `registerCustomer()` AND `resetPassword()`.**
3701
+ > **Password policy, enforced on `registerCustomer()` AND `resetPassword()`.**
3700
3702
  > At least **8 characters**, with at least one **lowercase** letter, one
3701
3703
  > **uppercase** letter, one **digit**, and one **special character**
3702
3704
  > (`/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^a-zA-Z0-9]).{8,}$/`). A password that
@@ -3705,7 +3707,7 @@ if (intent.clientSdk?.renderType === 'sandbox') {
3705
3707
  >
3706
3708
  > `securepassword123` fails (no uppercase, no special). `Password123` fails (no
3707
3709
  > special). `SecurePass123!` passes. Mirror the full rule in your own client-side
3708
- > validation and in the field's helper text a form that only says "min 8
3710
+ > validation and in the field's helper text. A form that only says "min 8
3709
3711
  > characters" produces a 400 the shopper cannot explain, and render the server's
3710
3712
  > message verbatim when one comes back.
3711
3713
 
@@ -3754,7 +3756,7 @@ localStorage.removeItem('returnUrl');
3754
3756
  window.location.href = returnUrl;
3755
3757
  ```
3756
3758
 
3757
- > **`setCustomerToken()` is a plain field setter it does not touch the cart.**
3759
+ > **`setCustomerToken()` is a plain field setter; it does not touch the cart.**
3758
3760
  > Always follow it with `await client.syncCartOnLogin()`. Skip it and the
3759
3761
  > shopper's guest cart is never attached to their account, which quietly breaks
3760
3762
  > every identity-keyed feature: first-order discounts, per-customer coupon caps,
@@ -3893,8 +3895,8 @@ await client.reportSocialShare('instagram');
3893
3895
  #### Referrals & Birthday Gifts
3894
3896
 
3895
3897
  When the store enables referrals, `getLoyaltyStatus()` also returns the
3896
- member's share code (`referralCode`) and for customers who signed up via a
3897
- referral link their still-unused welcome coupon (`referralWelcomeCoupon`).
3898
+ member's share code (`referralCode`) and, for customers who signed up via a
3899
+ referral link, their still-unused welcome coupon (`referralWelcomeCoupon`).
3898
3900
 
3899
3901
  ```typescript
3900
3902
  // 1. The referrer shares a link you build around their code.
@@ -3928,7 +3930,7 @@ signup.
3928
3930
 
3929
3931
  #### Paid Loyalty Membership
3930
3932
 
3931
- Stores can offer a paid "premium membership" inside the loyalty program a
3933
+ Stores can offer a paid "premium membership" inside the loyalty program: a
3932
3934
  recurring charge (default every 30 days) that grants a points multiplier and
3933
3935
  other perks. Storefront or vibe-coded mode, requires `customerToken`. The
3934
3936
  customer must first have a saved card (vault one by checking out with
@@ -3966,7 +3968,7 @@ const cancelled = await client.cancelMembership();
3966
3968
 
3967
3969
  #### Embeddable Loyalty Widget
3968
3970
 
3969
- Drop the loyalty program into ANY website not just your SDK-connected
3971
+ Drop the loyalty program into ANY website, not just your SDK-connected
3970
3972
  storefront. Mint a short-lived widget session (never exposes the real
3971
3973
  `customerToken` to the embedding page) and point an `<iframe>` at it. The
3972
3974
  merchant enables this per-domain in the dashboard (Loyalty → Settings →
@@ -3980,7 +3982,7 @@ const { embedUrl } = await client.getLoyaltyWidgetSession();
3980
3982
  // <iframe src={embedUrl} width="360" height="420" style={{ border: 0 }} />
3981
3983
  ```
3982
3984
 
3983
- `embedUrl` expires in ~15 minutes re-call `getLoyaltyWidgetSession()` before
3985
+ `embedUrl` expires in ~15 minutes, so re-call `getLoyaltyWidgetSession()` before
3984
3986
  that (e.g. on page load) rather than caching it long-term. Storefront or
3985
3987
  vibe-coded mode, requires `customerToken`.
3986
3988
 
@@ -4187,7 +4189,7 @@ window.location.href = authorizationUrl;
4187
4189
 
4188
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()`.
4189
4191
 
4190
- Both outcomes land here success **and** failure. Read `auth_code` and exchange it for the JWT; never read the token from the URL.
4192
+ Both outcomes land here: success **and** failure. Read `auth_code` and exchange it for the JWT; never read the token from the URL.
4191
4193
 
4192
4194
  ```typescript
4193
4195
  // app/auth/callback/page.tsx
@@ -4511,9 +4513,9 @@ console.log(store.language); // 'en', 'he', etc.
4511
4513
 
4512
4514
  ### Traffic Analytics (built-in, no GA4 needed)
4513
4515
 
4514
- Brainerce has a **native cookieless analytics pipeline** visits, visitors, countries, sources, devices, conversion funnel visible in the merchant dashboard under **Dashboard → Traffic**. You don't need GA4, Meta Pixel, or any third-party script.
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.
4515
4517
 
4516
- **Option A Script tag (recommended, zero JS):**
4518
+ **Option A: Script tag (recommended, zero JS):**
4517
4519
 
4518
4520
  Add one line to your root layout `<head>`:
4519
4521
 
@@ -4527,7 +4529,7 @@ Add one line to your root layout `<head>`:
4527
4529
 
4528
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.
4529
4531
 
4530
- **Option B SDK method (JS import, same endpoint):**
4532
+ **Option B: SDK method (JS import, same endpoint):**
4531
4533
 
4532
4534
  ```typescript
4533
4535
  // Basic pageview — call this on route changes if you're not using t.js
@@ -4548,9 +4550,9 @@ client.trackEvent({
4548
4550
  client.trackEvent({ eventType: 'engagement', path: '/products/shoes', engagedMs: 12000 });
4549
4551
  ```
4550
4552
 
4551
- > `trackEvent()` is fire-and-forget errors are silently swallowed so a failed beacon never breaks your storefront. Available in `salesChannelId` and `storeId` modes.
4553
+ > `trackEvent()` is fire-and-forget; errors are silently swallowed so a failed beacon never breaks your storefront. Available in `salesChannelId` and `storeId` modes.
4552
4554
 
4553
- > **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.
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.
4554
4556
 
4555
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.
4556
4558
 
@@ -4558,7 +4560,7 @@ client.trackEvent({ eventType: 'engagement', path: '/products/shoes', engagedMs:
4558
4560
 
4559
4561
  ### Analytics (optional, server-side GA4 conversions)
4560
4562
 
4561
- 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.
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.
4562
4564
 
4563
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:
4564
4566
 
@@ -4574,8 +4576,8 @@ await client.addToCart(cart.id, { productId: 'prod_abc', quantity: 1 });
4574
4576
 
4575
4577
  ### Traffic attribution (automatic, zero-config)
4576
4578
 
4577
- The SDK also records where each visit came from the external referrer host
4578
- and any `utm_source`/`utm_medium`/`utm_campaign` as a **last non-direct
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
4579
4581
  touch** (`brainerce_attr` in localStorage, 30-day window). The captured values
4580
4582
  are auto-attached to `setCheckoutCustomer()` / `setShippingAddress()` and end
4581
4583
  up on the order, powering the dashboard's "orders from ChatGPT / Google / …"
@@ -4583,12 +4585,12 @@ reporting. Nothing to configure; a value you pass explicitly always wins.
4583
4585
 
4584
4586
  What this does:
4585
4587
 
4586
- - Idempotently injects `gtag.js` and initializes `dataLayer` (skips injection if you're already loading `gtag.js` yourself safe to call either way).
4587
- - 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).
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).
4588
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.
4589
- - 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.
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.
4590
4592
 
4591
- 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.
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.
4592
4594
 
4593
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.
4594
4596
 
@@ -4598,7 +4600,7 @@ You still need to paste the GA4 **Measurement Protocol API secret** once in the
4598
4600
 
4599
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.
4600
4602
 
4601
- **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.
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.
4602
4604
 
4603
4605
  ```typescript
4604
4606
  const storeInfo = await client.getStoreInfo();
@@ -4608,7 +4610,7 @@ const storeInfo = await client.getStoreInfo();
4608
4610
  client.initTracking(storeInfo.tracking);
4609
4611
  ```
4610
4612
 
4611
- Then report what the shopper did once, in GA4's vocabulary. The SDK translates to each vendor (`dataLayer` push, `fbq` standard event, `ttq` event):
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):
4612
4614
 
4613
4615
  ```typescript
4614
4616
  client.trackMarketingEvent('view_item', { currency, value: price, items: [item] });
@@ -4631,11 +4633,11 @@ client.trackMarketingEvent('purchase', {
4631
4633
 
4632
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`.
4633
4635
 
4634
- ⛔ **`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.
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.
4635
4637
 
4636
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.
4637
4639
 
4638
- **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:
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:
4639
4641
 
4640
4642
  ```
4641
4643
  connect-src … https://www.googletagmanager.com https://www.google-analytics.com
@@ -4645,19 +4647,19 @@ connect-src … https://www.googletagmanager.com https://www.google-analytics.co
4645
4647
  https://analytics.tiktok.com
4646
4648
  ```
4647
4649
 
4648
- Under `script-src 'strict-dynamic'` you do **not** add these to `script-src` host allowlists are ignored there, and trust propagates from your nonce'd bundle to the tag scripts it injects.
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.
4649
4651
 
4650
- **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.
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.
4651
4653
 
4652
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.
4653
4655
 
4654
- > `trackMarketingEvent()` (ad platforms) is a different method from `trackEvent()` (Brainerce's own cookieless traffic analytics). Call both they answer different questions.
4656
+ > `trackMarketingEvent()` (ad platforms) is a different method from `trackEvent()` (Brainerce's own cookieless traffic analytics). Call both; they answer different questions.
4655
4657
 
4656
4658
  ---
4657
4659
 
4658
4660
  ## Admin API Reference
4659
4661
 
4660
- > ⛔ **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)).
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)).
4661
4663
 
4662
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.
4663
4665
 
@@ -4754,11 +4756,11 @@ await client.bulkSaveVariants(variableProduct.id, {
4754
4756
  });
4755
4757
  ```
4756
4758
 
4757
- **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.
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.
4758
4760
 
4759
4761
  ### Bulk Product Creation (catalog import)
4760
4762
 
4761
- Importing a catalog a supplier feed, a CSV/Excel export, a store migration
4763
+ Importing a catalog (a supplier feed, a CSV/Excel export, a store migration)
4762
4764
  should not be thousands of `createProduct` calls. `bulkCreateProducts` takes an
4763
4765
  array and returns a **job id**: the work is queued, and the products appear over
4764
4766
  the following seconds or minutes.
@@ -4806,7 +4808,7 @@ while (status.status === 'QUEUED' || status.status === 'RUNNING') {
4806
4808
  console.log(status.succeeded, status.skipped, status.failed, status.pending);
4807
4809
  ```
4808
4810
 
4809
- `COMPLETED_WITH_ERRORS` means the import finished and some rows failed — there
4811
+ `COMPLETED_WITH_ERRORS` means the import finished and some rows failed. There
4810
4812
  is nothing to re-run. Read the failures instead; each carries the 1-indexed
4811
4813
  `row` from the array you submitted, so it maps back to the line of the source
4812
4814
  spreadsheet:
@@ -4846,7 +4848,7 @@ chunks and leaves `finishedAt` null until every one has finished, so a partial
4846
4848
  result can never read as a finished import.
4847
4849
 
4848
4850
  **Duplicates.** A row whose `sku` or `externalId` already exists in the store is
4849
- skipped rather than duplicated so a batch re-sent after a timeout cannot
4851
+ skipped rather than duplicated, so a batch re-sent after a timeout cannot
4850
4852
  create the catalog twice. This is a database-level check, so it still holds days
4851
4853
  later and across retries. Rows carrying **neither** a `sku` nor an `externalId`
4852
4854
  have nothing to match on and will be created again on a re-send; set
@@ -4858,13 +4860,13 @@ skipped.
4858
4860
  taken, the importer appends a suffix (`t-shirt`, `t-shirt-1`, ...) and imports
4859
4861
  the row, where `createProduct` returns a 400. Rows colliding with each other
4860
4862
  inside the same batch are resolved the same way, in submission order. That is
4861
- deliberate a spreadsheet with two "T-Shirt" rows should import, not fail but
4863
+ deliberate, since a spreadsheet with two "T-Shirt" rows should import rather than fail, but
4862
4864
  it means the slug you sent is not always the slug you get. Read it back from
4863
4865
  the product if you depend on it.
4864
4866
 
4865
4867
  **Channel sync.** By default (`syncMode: 'coalesced'`) the per-product push to
4866
4868
  connected sales channels is suppressed during the import and one sync per
4867
- affected channel is filed at the end connectors are rate-limited per catalog,
4869
+ affected channel is filed at the end, because connectors are rate-limited per catalog,
4868
4870
  and a per-product fan-out would exhaust those limits. Use `syncMode: 'none'` to
4869
4871
  write to Brainerce only.
4870
4872
 
@@ -4970,13 +4972,13 @@ const swatches = getProductSwatches(product);
4970
4972
  ```
4971
4973
 
4972
4974
  > **Editing or deleting a single option is dashboard-only.** `/api/v1/attributes/:id/options`
4973
- > exposes exactly two verbs `GET` (list) and `POST` (add). There is no per-option route
4975
+ > exposes exactly two verbs: `GET` (list) and `POST` (add). There is no per-option route
4974
4976
  > on the public API, so `client.updateAttributeOption()` and `client.deleteAttributeOption()`
4975
4977
  > return **404**. The per-option routes exist only on the dashboard API
4976
4978
  > (`PUT` / `DELETE /api/stores/:storeId/attributes/:id/options/:optionId`), behind Clerk
4977
4979
  > auth. Recolour a swatch or drop an option in the dashboard. Everything else on
4978
- > attributes create, list, `updateAttribute`, `deleteAttribute`, `getAttributeOptions`,
4979
- > `createAttributeOption` works from the SDK as shown above.
4980
+ > attributes (create, list, `updateAttribute`, `deleteAttribute`, `getAttributeOptions`,
4981
+ > `createAttributeOption`) works from the SDK as shown above.
4980
4982
 
4981
4983
  ### Shipping Configuration
4982
4984
 
@@ -5039,8 +5041,8 @@ await client.createZoneShippingRate('zone_id', {
5039
5041
 
5040
5042
  ### App Store Shipping (live carrier rates)
5041
5043
 
5042
- Once a merchant installs a shipping app from the Brainerce App Store EasyPost, Shippo, or any
5043
- future carrier and connects their own carrier account, live rates appear automatically at
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
5044
5046
  checkout. Billing goes directly to the merchant's carrier account.
5045
5047
 
5046
5048
  Every carrier app implements the same Brainerce shipping contract, so this code is identical
@@ -5062,11 +5064,11 @@ console.log(label.carrier); // e.g. 'USPS', 'UPS', 'FedEx'
5062
5064
  console.log(label.labelFormat); // What the carrier actually produced
5063
5065
  ```
5064
5066
 
5065
- The `trackingNumber` is stored on the `Order` automatically customers see it in their
5067
+ The `trackingNumber` is stored on the `Order` automatically, and customers see it in their
5066
5068
  order history without any extra integration work.
5067
5069
 
5068
5070
  **Buy the service the shopper paid for.** `order.shippingSelection` records the live carrier
5069
- service that was sold at checkout `{ carrier, service, methodName, amount }` or `null` when
5071
+ service that was sold at checkout as `{ carrier, service, methodName, amount }`, or `null` when
5070
5072
  the order sold a flat-rate/zone rate and there is nothing to match. Rate ids do not survive a
5071
5073
  re-quote, so re-find it on `carrier` + `service`, trimmed and lower-cased:
5072
5074
 
@@ -5084,12 +5086,12 @@ const preferred = paidFor
5084
5086
 
5085
5087
  Buying a cheaper, slower service than the one the shopper was charged for is a silent
5086
5088
  downgrade of what they bought. When the paid-for service is not in the fresh quote, say so and
5087
- let a human choose do not substitute one automatically.
5089
+ let a human choose, and do not substitute one automatically.
5088
5090
 
5089
5091
  **Tracking updates are automatic.** Once the label exists, the carrier's tracking webhooks
5090
- flow back through the shipping app and move the shipment through its lifecycle in transit,
5092
+ flow back through the shipping app and move the shipment through its lifecycle: in transit,
5091
5093
  out for delivery, delivered. On delivery the order is completed and the customer notification
5092
- fires. You never poll for status read the history when you want to show it:
5094
+ fires. You never poll for status; read the history when you want to show it:
5093
5095
 
5094
5096
  ```typescript
5095
5097
  const shipments = await admin.getOrderShipments(orderId);
@@ -5104,9 +5106,37 @@ for (const s of shipments) {
5104
5106
  ```
5105
5107
 
5106
5108
  **Quote immediately before you buy.** `getOrderShippingRates()` is what creates the shipment
5107
- at the carrier the rate id points at it — and carriers do not allow amending one afterwards.
5109
+ at the carrier, which the rate id points at, and carriers do not allow amending one afterwards.
5108
5110
  A rate held from an earlier call may no longer be purchasable.
5109
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
+
5110
5140
  ### Cross-border shipments
5111
5141
 
5112
5142
  Customs declarations are handled for you: the platform builds one from the order's line items
@@ -5116,8 +5146,8 @@ merchandise.
5116
5146
 
5117
5147
  One case needs the merchant: a US-origin export where any single commodity line exceeds
5118
5148
  **$2,500** cannot use the ordinary EEI exemption. The exporter must file with AES and supply
5119
- the resulting ITN. Brainerce deliberately does **not** assert the exemption on those shipments
5120
- it is a declaration to US Customs, not a formality so the carrier will refuse the label until
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
5121
5151
  a real citation is provided.
5122
5152
 
5123
5153
  ### Tax Configuration
@@ -5189,7 +5219,7 @@ await client.deleteTaxClass(food.id);
5189
5219
 
5190
5220
  **Storefront (public, no API key).** A storefront lists classes in `storeId`
5191
5221
  mode **or** vibe-coded mode (`salesChannelId: 'vc_*'`, gated on the
5192
- `products:read` scope every connection already has) storefront-safe fields
5222
+ `products:read` scope every connection already has), returning storefront-safe fields
5193
5223
  only (for a "9% VAT" transparency badge):
5194
5224
 
5195
5225
  ```typescript
@@ -5254,7 +5284,7 @@ await client.deleteRegion(eu.id);
5254
5284
 
5255
5285
  **Storefront (public, no API key).** A storefront fetches regions in `storeId`
5256
5286
  mode **or** vibe-coded mode (`salesChannelId: 'vc_*'`, gated on the
5257
- `products:read` scope every connection already has) only active regions, only
5287
+ `products:read` scope every connection already has), returning only active regions and only
5258
5288
  storefront-safe fields. `getStoreRegions()`, `getStoreRegion()`, and
5259
5289
  `getAutoRegion()` all work in both modes:
5260
5290
 
@@ -5304,18 +5334,18 @@ await client.setMetafieldPlatforms('def_id', {
5304
5334
 
5305
5335
  ### Per-Channel Publishing (Categories / Tags / Brands / Custom Fields)
5306
5336
 
5307
- Each of these entities can be **gated per vibe-coded site** — i.e., merchants
5337
+ Each of these entities can be **gated per vibe-coded site**: merchants
5308
5338
  choose which storefronts see which categories, tags, brands, and custom
5309
5339
  fields. Mirrors the pattern already used by Products and Coupons.
5310
5340
 
5311
- **Visibility semantics explicit opt-in:** an entity is visible to a
5341
+ **Visibility semantics, explicit opt-in:** an entity is visible to a
5312
5342
  vibe-coded site **only if** it has been explicitly published to that
5313
5343
  connection. Entities with no publish rows are invisible to every vibe-coded
5314
5344
  site, including in product responses (the related categories/brands/tags
5315
5345
  arrays and the metafields array on each product are filtered the same way).
5316
5346
  Merchants publish through the dashboard's per-row Platforms cell; for
5317
5347
  categories, tags and brands the admin SDK below does the same thing. Custom
5318
- fields are the exception see the note under the snippet.
5348
+ fields are the exception; see the note under the snippet.
5319
5349
 
5320
5350
  ```typescript
5321
5351
  // Publish a product to a sales channel (accepts record ID or vc_* connection ID)
@@ -5341,21 +5371,22 @@ const cat = await client.getCategory('cat_id');
5341
5371
  cat.channelPublishes; // [{ salesChannel: { id, name, connectionId } }, ...]
5342
5372
  ```
5343
5373
 
5344
- > **Custom fields are the exception publish them in the dashboard.**
5374
+ > **Custom fields are the exception: publish them in the dashboard.**
5345
5375
  > `client.publishMetafieldDefinitionToVibeCodedSite()` and
5346
5376
  > `client.unpublishMetafieldDefinitionFromVibeCodedSite()` target
5347
5377
  > `/api/v1/metafield-definitions/:id/publish-vibe-coded`, which the public API does not
5348
5378
  > expose; both return **404**. Only the dashboard API carries those routes
5349
5379
  > (`POST /api/stores/:storeId/metafield-definitions/:id/publish` and `…/unpublish`),
5350
- > behind Clerk auth. **Reading is unaffected** `getMetafieldDefinitions()` and
5380
+ > behind Clerk auth. **Reading is unaffected**: `getMetafieldDefinitions()` and
5351
5381
  > `getMetafieldDefinition()` both return `channelPublishes` exactly like the other three
5352
5382
  > entity types, so you can still see which sites a custom field is published to; you just
5353
5383
  > cannot change it from the SDK.
5354
5384
 
5355
5385
  > **`vibeCodedPublishes` and its `connection` sub-key are deprecated.** Both are
5356
- > still emitted as back-compat aliases of `channelPublishes` / `salesChannel` and
5357
- > are removed in SDK 2.0. Read `channelPublishes[].salesChannel` in new code —
5358
- > the customer section below already does.
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.
5359
5390
 
5360
5391
  **Cross-account isolation:** publishing only succeeds when the entity and the
5361
5392
  target vibe-coded connection both belong to the same account. Cross-account
@@ -5366,12 +5397,12 @@ exist for that account).
5366
5397
 
5367
5398
  Customers use the same publish/unpublish shape, with one important difference:
5368
5399
  **you almost never have to call it.** A customer is attached to a channel
5369
- automatically the moment they are seen on it when they register, sign in
5400
+ automatically the moment they are seen on it, when they register, sign in
5370
5401
  (including via OAuth), or complete a checkout there.
5371
5402
 
5372
5403
  There is one customer record per store, shared by every channel
5373
5404
  (`@@unique(storeId, email)`), so the same person shopping two of your
5374
- storefronts stays one customer with two channel rows never a duplicate.
5405
+ storefronts stays one customer with two channel rows, never a duplicate.
5375
5406
 
5376
5407
  ```typescript
5377
5408
  // Attach / detach by hand — for migrations and corrections only
@@ -5402,7 +5433,7 @@ not prevent that person from buying on that storefront, and the row comes back
5402
5433
  the next time they sign in or order there. There is no API to bar a customer
5403
5434
  from a sales channel.
5404
5435
 
5405
- ### Store Team Management dashboard-only
5436
+ ### Store Team Management: dashboard-only
5406
5437
 
5407
5438
  Each store has its own team with roles (`OWNER`, `MANAGER`, `STAFF`, `VIEWER`) and
5408
5439
  granular permissions, including per-sales-channel scoping. **Managing it is a dashboard
@@ -5425,12 +5456,12 @@ members in the dashboard.
5425
5456
 
5426
5457
  > **The older account-level methods are not a substitute for this.** `getTeamMembers`,
5427
5458
  > `getTeamInvitations`, `inviteTeamMember`, `resendTeamInvitation`, `revokeTeamInvitation`,
5428
- > `updateTeamMemberRole` and `removeTeamMember` do still reach `/api/v1/team/…` but they
5459
+ > `updateTeamMemberRole` and `removeTeamMember` do still reach `/api/v1/team/…`, but they
5429
5460
  > manage the **account** team, not a store's. They will not invite anyone to a store or
5430
5461
  > scope a member to a sales channel; only the dashboard does that.
5431
5462
  >
5432
5463
  > **For the account team, they remain the supported call.** All seven are tagged
5433
- > `@deprecated`, which records an intent to retire them not a migration you can perform
5464
+ > `@deprecated`, which records an intent to retire them, not a migration you can perform
5434
5465
  > today. There is no API-key replacement: the store-level methods named above are
5435
5466
  > dashboard-only. Keep using these until an API-key route ships, and expect the tag to
5436
5467
  > outlive this note.
@@ -5594,7 +5625,7 @@ await client.updateAttachment(storeId, productId, attachment.id, { position: 1 }
5594
5625
  await client.detachModifierGroup(storeId, productId, attachment.id);
5595
5626
  ```
5596
5627
 
5597
- `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.
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.
5598
5629
 
5599
5630
  ### Review Moderation
5600
5631
 
@@ -5646,6 +5677,66 @@ await client.showProductReviewImage('revimg_123', storeId);
5646
5677
  > stores that turned approval on. Poll `adminListProductReviews(productId, { visibility: 'all' })`
5647
5678
  > and treat any image with `approvedAt: null` as the queue.
5648
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.
5739
+
5649
5740
  ---
5650
5741
 
5651
5742
  ## Complete Page Examples
@@ -6041,7 +6132,7 @@ export default function CartPage() {
6041
6132
 
6042
6133
  ### Checkout Page
6043
6134
 
6044
- > **RECOMMENDED:** Use this unified pattern the `smart*` methods handle both guest and logged-in users.
6135
+ > **RECOMMENDED:** Use this unified pattern; the `smart*` methods handle both guest and logged-in users.
6045
6136
 
6046
6137
  ```typescript
6047
6138
  'use client';
@@ -6644,7 +6735,7 @@ try {
6644
6735
 
6645
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.
6646
6737
 
6647
- **Simple (legacy always supported):**
6738
+ **Simple (legacy, always supported):**
6648
6739
 
6649
6740
  ```typescript
6650
6741
  await brainerce.createInquiry({
@@ -6674,7 +6765,7 @@ const forms = await brainerce.contactForms.list();
6674
6765
  // → [{ key, name, isDefault }, ...]
6675
6766
  ```
6676
6767
 
6677
- **Rate limit:** 3 submissions per 60 seconds per IP. Include a hidden honeypot field (and do not submit it) bots that auto-fill every input will be rejected.
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.
6678
6769
 
6679
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).
6680
6771
 
@@ -6696,17 +6787,17 @@ await brainerce.marketing.subscribe({
6696
6787
 
6697
6788
  Also accepts `firstName`, `lastName`, and `sourceMetadata` (referrer, UTM params, the page the popup fired on).
6698
6789
 
6699
- **⛔ 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.
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.
6700
6791
 
6701
- **⛔ 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.
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.
6702
6793
 
6703
- **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.
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.
6704
6795
 
6705
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.
6706
6797
 
6707
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.
6708
6799
 
6709
- 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 no password, no account and is the same row if that person later registers or checks out.
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.
6710
6801
 
6711
6802
  ---
6712
6803
 
@@ -6725,9 +6816,9 @@ await brainerce.stockAlerts.subscribe({
6725
6816
  // → { ok: true }
6726
6817
  ```
6727
6818
 
6728
- **⛔ 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.
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.
6729
6820
 
6730
- **⛔ 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.
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.
6731
6822
 
6732
6823
  ```typescript
6733
6824
  const store = await brainerce.getStoreInfo();
@@ -6743,11 +6834,11 @@ const canOfferStockAlert =
6743
6834
 
6744
6835
  `backorderMode` is on `InventoryInfo` from SDK 1.61; older backends omit it, so treat `undefined` as `'NONE'`.
6745
6836
 
6746
- The merchant controls the switch and how many people are emailed per unit restocked under **Channel settings → Inventory**, alongside the low-stock warning.
6837
+ The merchant controls the switch, and how many people are emailed per unit restocked, under **Channel settings → Inventory**, alongside the low-stock warning.
6747
6838
 
6748
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.
6749
6840
 
6750
- **⛔ 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.
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.
6751
6842
 
6752
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".
6753
6844
 
@@ -6755,7 +6846,7 @@ The merchant controls the switch — and how many people are emailed per unit re
6755
6846
 
6756
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.
6757
6848
 
6758
- **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.
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.
6759
6850
 
6760
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.
6761
6852
 
@@ -6763,7 +6854,7 @@ The merchant reads the demand at `Products → Back-in-Stock Waitlist`: products
6763
6854
 
6764
6855
  ## Storefront Bot (AI chat widget)
6765
6856
 
6766
- 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 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.
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.
6767
6858
 
6768
6859
  ```html
6769
6860
  <!-- zero-code embed: keep the tag exactly this bare (no integrity/crossorigin) -->
@@ -6791,9 +6882,9 @@ bot?.destroy(); // optional teardown
6791
6882
 
6792
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.
6793
6884
 
6794
- **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 shoppers can never mutate the store through it.
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.
6795
6886
 
6796
- **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.
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.
6797
6888
 
6798
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`).
6799
6890
 
@@ -6842,29 +6933,29 @@ export async function POST(req: Request) {
6842
6933
  backend validates the `events` array on create against exactly this list, so
6843
6934
  anything outside it is rejected rather than silently accepted.
6844
6935
 
6845
- | Event | Description |
6846
- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
6847
- | `order.created` | New order placed (any payment status) |
6848
- | `order.updated` | Order metadata changed (status, address, items) |
6849
- | `order.paid` | Order is paid 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 |
6850
- | `order.fulfilled` | All items marked shipped/delivered |
6851
- | `order.cancelled` | Order cancelled (by merchant or customer) |
6852
- | `order.refunded` | Order fully or partially refunded |
6853
- | `customer.created` | New customer account created |
6854
- | `customer.updated` | Customer profile or contact details changed |
6855
- | `customer.deleted` | Customer account deleted |
6856
- | `product.created` | New product added to catalog |
6857
- | `product.updated` | Product attributes, variants, or pricing changed |
6858
- | `product.deleted` | Product removed from catalog |
6859
- | `inventory.updated` | Stock level changed (any reason) |
6860
- | `inventory.low` | Stock fell below the low-stock threshold |
6861
- | `checkout.completed` | Checkout completed (synonym of `order.created` for now) |
6862
- | `checkout.abandoned` | Cart inactive for 1+ hours with no completion |
6863
- | `payment.succeeded` | Payment provider confirmed funds captured |
6864
- | `payment.failed` | Payment provider rejected the transaction |
6865
- | `payment.refunded` | Refund posted to the customer |
6866
- | `blog.post.published` | Post went live (manual, scheduled, or SEO Autopilot) |
6867
- | `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 |
6868
6959
 
6869
6960
  Payload shapes for each are in the
6870
6961
  [Event Catalogue](https://brainerce.com/docs/webhooks/events).
@@ -6873,27 +6964,14 @@ Payload shapes for each are in the
6873
6964
  just merchant-created customers, so a storefront that registers customers will
6874
6965
  start seeing it.
6875
6966
 
6876
- > **⚠️ The `WebhookEventType` type does not match this table yet in both
6877
- > directions.** Treat the table, not the type, as the truth about what you can
6878
- > subscribe to.
6879
- >
6880
- > **14 subscribable events are missing from the type:** `order.paid`,
6881
- > `order.fulfilled`, `order.cancelled`, `order.refunded`, `customer.created`,
6882
- > `customer.updated`, `customer.deleted`, `inventory.low`, `checkout.abandoned`,
6883
- > `payment.succeeded`, `payment.failed`, `payment.refunded`,
6884
- > `blog.post.published`, `blog.post.updated`. So
6885
- > `isWebhookEventType(event, 'customer.created')` and a
6886
- > `createWebhookHandler({ 'order.paid': … })` key **fail to compile**, even
6887
- > though both deliver correctly at runtime. Cast the name
6888
- > (`'customer.created' as WebhookEventType`) or read `event.event` as a
6889
- > `string` and switch on it yourself. Do not conclude the event does not exist.
6890
- >
6891
- > **8 names in the type cannot be subscribed to at all:** `coupon.created`,
6892
- > `coupon.updated`, `coupon.deleted`, `cart.created`, `cart.updated`,
6893
- > `cart.abandoned`, `checkout.started`, `checkout.failed`. These compile
6894
- > cleanly and then fail at subscription time. `cart.abandoned` in particular
6895
- > was listed as a supported event here for a long time — use
6896
- > `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.)
6897
6975
 
6898
6976
  ---
6899
6977