brainerce 1.58.0 → 1.59.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,14 +7,31 @@ This SDK provides a complete solution for vibe-coded sites, AI-built stores (Cur
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
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
- ## Two SDK modes — choose the right one
10
+ ## Three SDK modes — choose the right one
11
11
 
12
- | Mode | Config key | Use for | Where to run |
13
- | -------------- | ------------------------ | ---------------------------------- | ----------------------------------- |
14
- | **Storefront** | `salesChannelId: 'vc_*'` | Building the customer-facing store | Browser / client-side |
15
- | **Admin** | `apiKey: 'brainerce_*'` | Managing products, team, settings | Server only — never in browser code |
12
+ The exported class is **`BrainerceClient`**. Which mode you get is decided by which key you pass to its constructor:
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 |
19
+
20
+ ```ts
21
+ import { BrainerceClient } from 'brainerce';
22
+
23
+ const client = new BrainerceClient({ salesChannelId: 'vc_abc123' });
24
+ ```
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`.
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()`.)
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.
16
31
 
17
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
+ >
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.
18
35
 
19
36
  ## Installation
20
37
 
@@ -68,7 +85,7 @@ Violating any of these causes production incidents or broken orders. Read them b
68
85
  - ALWAYS call SDK client methods. Never reconstruct REST URLs or call `fetch` directly.
69
86
  - NEVER invent SDK method names. If it's not in this README or in `get-sdk-docs`, it doesn't exist.
70
87
  - NEVER hardcode product data, categories, or store copy — Brainerce is the database.
71
- - NEVER use `submitGuestOrder()` or `createOrder()` — they bypass payment and produce unpaid orders.
88
+ - 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.
72
89
  - ALWAYS use SDK helpers (`getCartTotals`, `formatPrice`, `getProductPriceInfo`, `getCartItemImage`, `getCartItemName`, `getVariantPrice`, `getStockStatus`, `getDescriptionContent`) instead of reading raw fields.
73
90
 
74
91
  ### State management
@@ -269,33 +286,33 @@ the credential, no customer token needed.
269
286
 
270
287
  The SDK exports these utility functions for common UI tasks:
271
288
 
272
- | Function | Purpose | Example |
273
- | ---------------------------------------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
274
- | `formatPrice(amount, { currency?, locale? })` | Format prices for display | `formatPrice("99.99", { currency: 'USD' })` → `$99.99` |
275
- | `getPriceDisplay(amount, currency?, locale?)` | Alias for `formatPrice` | Same as above |
276
- | `getDescriptionContent(product)` | Get product description (HTML or text) | `getDescriptionContent(product)` |
277
- | `isHtmlDescription(product)` | Check if description is HTML | `isHtmlDescription(product)` → `true/false` |
278
- | `getStockStatus(inventory)` | Get human-readable stock status | `getStockStatus(inventory)` → `"In Stock"` |
279
- | `getProductPrice(product)` | Get effective price (handles sales) | `getProductPrice(product)` → `29.99` |
280
- | `getProductPriceInfo(product)` | Get price + sale info + discount % (falls back to `priceMin` when `basePrice=0` on VARIABLE) | `{ price, isOnSale, discountPercent }` |
281
- | `getVariantPrice(variant, basePrice)` | Get variant price with fallback | `getVariantPrice(variant, '29.99')` → `34.99` |
282
- | `getCartTotals(cart, shippingPrice?)` | Calculate cart subtotal/discount/total | `{ subtotal, discount, shipping, total }` |
283
- | `getCartItemName(item)` | Get name from nested cart item (product + variant) | `getCartItemName(item)` → `"Blue T-Shirt - Large"` |
284
- | `getCartItemImage(item)` | Get image URL from cart item | `getCartItemImage(item)` → `"https://..."` |
285
- | `getVariantOptions(variant)` | Get variant attributes as array | `[{ name: "Color", value: "Red" }]` |
286
- | `isCouponApplicableToProduct(coupon, product)` | Check if coupon applies | `isCouponApplicableToProduct(coupon, product)` |
287
- | `isAllowedPaymentUrl(url, options?)` | Validate a payment URL host | `isAllowedPaymentUrl(intent.clientSecret)` → `true` |
288
- | `safePaymentRedirect(url, options?)` | Validate then `window.location.href` | `safePaymentRedirect(intent.clientSecret)` |
289
- | `buildProductJsonLd(product, opts)` | schema.org Product JSON-LD (PDPs only) | See SEO section |
290
- | `buildArticleJsonLd(post, opts)` | schema.org Article JSON-LD for blog posts | See SEO section |
291
- | `buildOrganizationJsonLd(store, opts)` | schema.org Organization for the homepage | See SEO section |
292
- | `buildBreadcrumbJsonLd(items)` | schema.org BreadcrumbList | See SEO section |
293
- | `buildProductFaqJsonLd(product)` | schema.org FAQPage from `product.faq` (null when empty) — render the same pairs as visible text | `const faq = buildProductFaqJsonLd(product)` |
294
- | `jsonLdScriptProps(data)` | XSS-safe `<script type="application/ld+json">` props | `<script {...jsonLdScriptProps(data)} />` |
295
- | `getBlogSitemapEntries(client, opts)` | Paginate published posts into sitemap entries | See SEO section |
296
- | `getProductSitemapEntries(client, opts)` | ALL published products into sitemap entries (no 100-item clamp) | See SEO section |
297
- | `getCategorySitemapEntries(client, opts)` | Category tree into sitemap entries | See SEO section |
298
- | `client.resolveSlugRedirect(type, slug)` | Renamed slug → current slug (301 support in not-found paths) | See SEO section |
289
+ | Function | Purpose | Example |
290
+ | ---------------------------------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
291
+ | `formatPrice(amount, { currency?, locale? })` | Format prices for display | `formatPrice("99.99", { currency: 'USD' })` → `$99.99` |
292
+ | `getPriceDisplay(amount, currency?, locale?)` | Alias for `formatPrice` | Same as above |
293
+ | `getDescriptionContent(product)` | Get product description (HTML or text) | `getDescriptionContent(product)` |
294
+ | `isHtmlDescription(product)` | Check if description is HTML | `isHtmlDescription(product)` → `true/false` |
295
+ | `getStockStatus(inventory)` | Get human-readable stock status | `getStockStatus(inventory)` → `"In Stock"` |
296
+ | `getProductPrice(product)` | Get effective price (handles sales) | `getProductPrice(product)` → `29.99` |
297
+ | `getProductPriceInfo(product)` | Get price + sale info + discount % (falls back to `priceMin` when `basePrice=0` on VARIABLE) | `{ price, isOnSale, discountPercent }` |
298
+ | `getVariantPrice(variant, basePrice)` | Get variant price with fallback | `getVariantPrice(variant, '29.99')` → `34.99` |
299
+ | `getCartTotals(cart, shippingPrice?)` | Calculate cart subtotal/discount/total | `{ subtotal, discount, shipping, total }` |
300
+ | `getCartItemName(item)` | Get name from nested cart item (product + variant) | `getCartItemName(item)` → `"Blue T-Shirt - Large"` |
301
+ | `getCartItemImage(item)` | Get image URL from cart item | `getCartItemImage(item)` → `"https://..."` |
302
+ | `getVariantOptions(variant)` | Get variant attributes as array | `[{ name: "Color", value: "Red" }]` |
303
+ | `isCouponApplicableToProduct(coupon, product)` | Check if coupon applies | `isCouponApplicableToProduct(coupon, product)` |
304
+ | `isAllowedPaymentUrl(url, options?)` | Validate a payment URL host | `isAllowedPaymentUrl(intent.clientSecret)` → `true` |
305
+ | `safePaymentRedirect(url, options?)` | Validate then `window.location.href` | `safePaymentRedirect(intent.clientSecret)` |
306
+ | `buildProductJsonLd(product, opts)` | schema.org Product JSON-LD (PDPs only) | See SEO section |
307
+ | `buildArticleJsonLd(post, opts)` | schema.org Article JSON-LD for blog posts | See SEO section |
308
+ | `buildOrganizationJsonLd(store, opts)` | schema.org Organization for the homepage | See SEO section |
309
+ | `buildBreadcrumbJsonLd(items)` | schema.org BreadcrumbList | See SEO section |
310
+ | `buildProductFaqJsonLd(product)` | schema.org FAQPage from `product.faq` (null when empty) — render the same pairs as visible text | `const faq = buildProductFaqJsonLd(product)` |
311
+ | `jsonLdScriptProps(data)` | XSS-safe `<script type="application/ld+json">` props | `<script {...jsonLdScriptProps(data)} />` |
312
+ | `getBlogSitemapEntries(client, opts)` | Paginate published posts into sitemap entries | See SEO section |
313
+ | `getProductSitemapEntries(client, opts)` | ALL published products into sitemap entries (no 100-item clamp) | See SEO section |
314
+ | `getCategorySitemapEntries(client, opts)` | Category tree into sitemap entries | See SEO section |
315
+ | `client.resolveSlugRedirect(type, slug)` | Renamed slug → current slug (301 support in not-found paths) | See SEO section |
299
316
 
300
317
  ```typescript
301
318
  import {
@@ -419,7 +436,7 @@ await client.addToCart(cart.id, {
419
436
  });
420
437
  ```
421
438
 
422
- 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'` and `errors[]` — see INTEGRATION-RULES.md "Modifier validation errors" for the full code list.
439
+ 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.
423
440
 
424
441
  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).
425
442
 
@@ -573,14 +590,16 @@ if (result.tracked) {
573
590
  // Continue with payment flow...
574
591
  }
575
592
 
576
- // ALTERNATIVE - Use submitGuestOrder() for simple checkout without payment UI
593
+ // ⚠️ CASH-ON-DELIVERY / SANDBOX ONLY - submitGuestOrder() places the order with
594
+ // NO payment collected. Never call it on a store with a payment provider.
577
595
  const order = await client.submitGuestOrder();
578
596
  ```
579
597
 
580
598
  **Rule of thumb:**
581
599
 
582
- - Guest user + Session cart → `startGuestCheckout()` or `submitGuestOrder()`
600
+ - Guest user + Session cart → `startGuestCheckout()`
583
601
  - Logged-in user + Server cart → `createCheckout({ cartId })`
602
+ - Store that collects no money at checkout (cash on delivery, manual invoice, sandbox) → `submitGuestOrder()`
584
603
 
585
604
  ### 2. ⛔ NEVER Create Local Interfaces - Use SDK Types!
586
605
 
@@ -1033,6 +1052,16 @@ text. Suggestions come from Google Places; each resolved address is flagged
1033
1052
  `inZone` against the store's configured shipping zones — a soft signal for a
1034
1053
  warning banner, never a hard block.
1035
1054
 
1055
+ Suggestions are limited to deliverable address types (street addresses, routes,
1056
+ buildings, sub-premises). Businesses, stations and other establishments are
1057
+ never returned — a courier cannot deliver to one. A shopper who types only a
1058
+ landmark name gets an empty list and has to type the street.
1059
+
1060
+ `inZone` resolves a zone's currency-region restriction the same way the checkout
1061
+ does — destination country first, then the `regionId` you pass, then the store's
1062
+ default region — so a `true` here is not contradicted by the rates you fetch
1063
+ afterwards.
1064
+
1036
1065
  ```typescript
1037
1066
  const sessionToken = crypto.randomUUID(); // one per address-entry attempt
1038
1067
 
@@ -1062,6 +1091,9 @@ if (!address.country) {
1062
1091
  // guaranteed) the same ISO 3166-2 subdivision code this store's own region
1063
1092
  // lists use. Validate against destinations.regions before trusting it;
1064
1093
  // never assign a code the region <select> wouldn't recognize.
1094
+ // NOTE: getShippingDestinations() is CHANNEL-ONLY — it needs a `salesChannelId`
1095
+ // client. In `storeId` (public storefront) mode it targets a path the API does not
1096
+ // expose and returns 404, so drive the region <select> from your own list there.
1065
1097
  const destinations = await client.getShippingDestinations();
1066
1098
  const validRegions = destinations.regions[address.country] ?? [];
1067
1099
  const region = validRegions.some((r) => r.code === address.region) ? address.region : '';
@@ -1656,27 +1688,58 @@ function SearchInput() {
1656
1688
 
1657
1689
  #### Product Type Definition
1658
1690
 
1691
+ > **The shipped `.d.ts` is the authority.** These are abridged for reading —
1692
+ > import the real types (`import type { Product, ProductVariant } from 'brainerce'`)
1693
+ > rather than retyping them. See the Critical Rule: _never write your own copies
1694
+ > of SDK types._
1695
+
1659
1696
  ```typescript
1660
1697
  interface Product {
1661
1698
  id: string;
1662
1699
  name: string;
1663
- description?: string | null;
1664
- descriptionFormat?: 'text' | 'html' | 'markdown'; // Format of description content
1700
+ slug?: string | null; // link target for /products/{slug}
1701
+ localeSlugs?: Record<string, string> | null; // per-locale slugs for hreflang / sitemaps
1702
+ description?: string | null; // HTML — sanitize before rendering (may contain <video>/<iframe>)
1703
+ descriptionFormat?: 'text' | 'html' | 'markdown' | null;
1665
1704
  sku: string;
1705
+ gtin?: string | null; // EAN/UPC/ISBN — emitted in Product JSON-LD
1706
+ mpn?: string | null; // Manufacturer Part Number — emitted in Product JSON-LD
1707
+ faq?: Array<{ q: string; a: string }> | null; // render as a FAQ section + buildProductFaqJsonLd()
1666
1708
  basePrice: string; // Decimal as string — use parseFloat() for calculations
1667
1709
  salePrice?: string | null;
1668
1710
  salePriceStartsAt?: string | null; // ISO 8601 — sale-price effective window start
1669
1711
  salePriceEndsAt?: string | null; // ISO 8601 — sale-price window end; feeds buildProductJsonLd's priceValidUntil
1712
+ costPrice?: string | null; // COGS — admin (apiKey) reads only
1670
1713
  priceMin?: string | null; // Lowest variant price (VARIABLE products only)
1671
1714
  priceMax?: string | null; // Highest variant price (VARIABLE products only)
1672
1715
  priceVaries?: boolean; // true when range should be shown ("₪49 – ₪199")
1716
+ // Region FX overlay — present ONLY with getProducts({ regionId }). DISPLAY-ONLY;
1717
+ // basePrice/salePrice stay in the store currency. Use formatProductPrice().
1718
+ displayPrice?: string;
1719
+ displaySalePrice?: string;
1720
+ displayPriceMin?: string;
1721
+ displayPriceMax?: string;
1722
+ displayCurrency?: string; // ISO 4217 of the display* fields
1673
1723
  status: string; // e.g. "active" | "draft"
1674
1724
  type: 'SIMPLE' | 'VARIABLE';
1725
+ isDownloadable?: boolean;
1726
+ downloads?: DownloadFile[] | null; // when isDownloadable
1675
1727
  images?: ProductImage[];
1676
1728
  inventory?: InventoryInfo | null;
1677
1729
  variants?: ProductVariant[];
1678
1730
  categories?: Array<{ id: string; name: string; slug?: string | null }>; // NOT string[] — use slug to link to /category/{slug}
1731
+ brands?: Array<{ id: string; name: string }>; // objects, not string[]
1679
1732
  tags?: string[];
1733
+ metafields?: ProductMetafield[]; // custom fields — check field.type before rendering
1734
+ customizationFields?: ProductCustomizationField[]; // buyer input (engraving, uploads…)
1735
+ modifierGroups?: ModifierGroup[]; // add-ons / options priced per selection
1736
+ discount?: ProductDiscount | null; // active rule-based discount on this product
1737
+ avgRating?: number; // feeds JSON-LD aggregateRating
1738
+ reviewCount?: number;
1739
+ needsSync: boolean; // REQUIRED — always returned
1740
+ taxBehavior?: 'taxable' | 'exempt';
1741
+ menuOrder?: number | null;
1742
+ channelPublishes?: Array<{ salesChannel: { id: string; name: string; connectionId: string } }>; // admin mode only
1680
1743
  createdAt: string;
1681
1744
  updatedAt: string;
1682
1745
  }
@@ -1685,28 +1748,48 @@ interface ProductImage {
1685
1748
  url: string;
1686
1749
  position?: number;
1687
1750
  isMain?: boolean;
1751
+ alt?: string;
1752
+ // admin (apiKey) mode only: id, key, thumbnailUrl, width, height, size, mimeType, createdAt
1688
1753
  }
1689
1754
 
1690
1755
  interface ProductVariant {
1691
1756
  id: string;
1757
+ productId: string; // REQUIRED — parent product
1692
1758
  sku?: string | null;
1693
1759
  name?: string | null;
1694
- price?: number | null;
1695
- salePrice?: number | null;
1696
- attributes?: Record<string, string>;
1760
+ price?: string | null; // STRING, not number — parseFloat() before any math
1761
+ salePrice?: string | null; // STRING, not number
1762
+ costPrice?: string | null; // COGS override — admin (apiKey) reads only
1763
+ // Region FX overlay — present ONLY with getProducts({ regionId }). Display-only.
1764
+ displayPrice?: string;
1765
+ displaySalePrice?: string;
1766
+ displayCurrency?: string;
1767
+ attributes?: Record<string, string> | null; // e.g. { "Color": "Red", "Size": "M" }
1768
+ options?: Array<{ name: string; value: string }>; // alternative shape — use getVariantOptions()
1697
1769
  inventory?: InventoryInfo | null;
1770
+ image?: string | { url: string; thumbnailUrl?: string } | null;
1771
+ position: number; // REQUIRED — display order
1772
+ status?: string | null;
1773
+ createdAt: string; // REQUIRED
1774
+ updatedAt: string; // REQUIRED
1698
1775
  }
1699
1776
 
1700
1777
  interface InventoryInfo {
1701
1778
  total: number;
1702
1779
  reserved: number;
1703
1780
  available: number;
1704
- trackingMode?: 'TRACKED' | 'UNLIMITED' | 'DISABLED';
1781
+ trackingMode: InventoryTrackingMode; // REQUIRED — 'TRACKED' | 'UNLIMITED' | 'DISABLED'
1705
1782
  inStock: boolean; // Pre-calculated - use this for display!
1706
1783
  canPurchase: boolean; // Pre-calculated - use this for add-to-cart
1784
+ lastInventorySyncAt?: string | null; // admin mode only
1707
1785
  }
1708
1786
  ```
1709
1787
 
1788
+ > **Variant prices are strings.** `variant.price` and `variant.salePrice` are
1789
+ > `string | null`, exactly like `product.basePrice`. `variant.price > 100` compares
1790
+ > lexicographically and silently returns the wrong answer — always `parseFloat()`
1791
+ > first, or use `getVariantPrice(variant)` / `formatVariantPrice(variant)`.
1792
+
1710
1793
  #### Product Metafields (Custom Fields)
1711
1794
 
1712
1795
  Products can have custom fields (metafields) defined by the store owner, such as "Material", "Care Instructions", or "Warranty".
@@ -1897,15 +1980,20 @@ await client.addToCart(cartId, {
1897
1980
  | GALLERY | `string[]` (URLs) | Multi-file upload |
1898
1981
  | DIMENSION/WEIGHT | `{ value, unit }` | Value + unit inputs |
1899
1982
 
1900
- **Admin methods** (require API key):
1901
-
1902
- ```typescript
1903
- // Set which customer-input definitions apply to a product
1904
- await client.setProductCustomizationFields(productId, [definitionId1, definitionId2]);
1905
-
1906
- // Get current assignments
1907
- const fields = await client.getProductCustomizationFields(productId);
1908
- ```
1983
+ **Assigning fields to a product is dashboard-only — there is no SDK path to it.**
1984
+ `client.setProductCustomizationFields()` and `client.getProductCustomizationFields()`
1985
+ target `/api/v1/metafield-definitions/products/:productId/customization-fields`, which
1986
+ the public API does not expose; both return **404**. Those routes exist only on the
1987
+ dashboard API (`/api/stores/:storeId/metafield-definitions/products/:productId/customization-fields`),
1988
+ behind Clerk auth. Choose which customer-input definitions apply to a product in the
1989
+ dashboard.
1990
+
1991
+ > **Two different things share the name `getProductCustomizationFields`.** The
1992
+ > **exported helper** used above — `import { getProductCustomizationFields } from 'brainerce'`
1993
+ > — is a pure function that reads the definitions off a product you already fetched. It
1994
+ > works in every mode and is the one you want. The **client method** of the same name,
1995
+ > which writes the assignment, is the one that 404s. Reading is fully covered without it:
1996
+ > `product.customizationFields` is already on every product response.
1909
1997
 
1910
1998
  > **Note:** `customizationFields` is only present when the product has customer input fields assigned. After checkout, customization values are preserved in the order as `item.customizations`.
1911
1999
 
@@ -2107,9 +2195,17 @@ const totals = getCartTotals(cart);
2107
2195
 
2108
2196
  ---
2109
2197
 
2110
- ### Guest Checkout (Submit Order)
2198
+ ### Guest Checkout (Submit Order) — no payment collected
2111
2199
 
2112
- > **Note:** `startGuestCheckout()` is the preferred method for guest checkout it creates a full checkout session from the session cart. `submitGuestOrder()` still works as a simpler alternative for basic orders.
2200
+ > **⛔ Not for stores that take payment.** `submitGuestOrder()` posts the order
2201
+ > straight to `POST /orders`. It never creates a payment intent, so the order is
2202
+ > created **unpaid** and no card is ever charged. Use it only where checkout
2203
+ > collects no money — cash on delivery, manual invoicing, or a sandbox store.
2204
+ >
2205
+ > For every other store use `startGuestCheckout()`, which creates a real checkout
2206
+ > session from the session cart and hands you a `checkoutId` to run the payment
2207
+ > flow against. This is the same rule as the Critical Rule above; the two used to
2208
+ > disagree.
2113
2209
 
2114
2210
  ```typescript
2115
2211
  // Make sure cart has items, customer email, and shipping address
@@ -2217,7 +2313,8 @@ if (checkout.tracked) {
2217
2313
  const order = await client.completeGuestCheckout(checkout.checkoutId);
2218
2314
  console.log('Order created:', order.orderId);
2219
2315
  } else {
2220
- // Fallback to regular guest checkout
2316
+ // Fallback NO payment is collected on this path. Only reachable on a
2317
+ // cash-on-delivery / manual-invoice / sandbox store.
2221
2318
  const order = await client.submitGuestOrder();
2222
2319
  }
2223
2320
  ```
@@ -2416,8 +2513,11 @@ await client.getProduct('prod_tshirt', { regionId: 'region_eu' });
2416
2513
  await client.getProductBySlug('blue-shirt', { regionId: 'region_eu' });
2417
2514
  ```
2418
2515
 
2419
- > **Display-only** like checkout, `regionId` here does not charge the region
2420
- > currency; it only changes what you render until currency-lock ships. Works in
2516
+ > **Display-only, and unlike checkout.** `regionId` on a _product read_ never
2517
+ > affects what is charged — it only changes what you render. `regionId` on
2518
+ > `createCheckout()` is different: it CAN charge the region currency (see
2519
+ > FX-at-checkout above). Do not carry the "display-only" assumption from here into
2520
+ > the checkout step. Works in
2421
2521
  > **all three modes** — vibe-coded (`vc_*`/`salesChannelId`), storefront (`storeId`),
2422
2522
  > and admin (`apiKey`). The response gains `displayPrice` whenever a daily FX rate
2423
2523
  > exists for the store/region currency pair in **either** direction (the overlay
@@ -2907,16 +3007,21 @@ if (paypalProvider) {
2907
3007
  }
2908
3008
  ```
2909
3009
 
2910
- #### Get Payment Configuration (Single Provider)
3010
+ #### Get Payment Configuration (Single Provider) — DEPRECATED
2911
3011
 
2912
- If you only need the default provider, use this simpler method:
3012
+ > **`getPaymentConfig()` is `@deprecated`.** It only ever describes one provider, so
3013
+ > a store with an additive express method (PayPal, a wallet) renders wrong. Use
3014
+ > `getPaymentProviders()` above. This section documents the legacy shape for code
3015
+ > that still calls it.
2913
3016
 
2914
3017
  ```typescript
2915
3018
  const config = await client.getPaymentConfig();
2916
3019
 
2917
3020
  // Returns:
2918
3021
  // {
2919
- // provider: 'stripe' | 'paypal',
3022
+ // provider: string, // 'stripe' | 'paypal' | 'grow' | 'cardcom' | any
3023
+ // // installed marketplace payment app — NOT a
3024
+ // // closed union. Never switch on it exhaustively.
2920
3025
  // publicKey: 'pk_live_xxx...', // Stripe publishable key or PayPal client ID
2921
3026
  // supportedMethods: ['card', 'ideal', 'bancontact'],
2922
3027
  // testMode: false
@@ -2933,13 +3038,74 @@ const intent = await client.createPaymentIntent(checkout.id);
2933
3038
  // Returns:
2934
3039
  // {
2935
3040
  // id: 'pi_xxx...',
2936
- // clientSecret: 'pi_xxx_secret_xxx', // Used by Stripe.js/PayPal SDK
2937
- // amount: 9999, // In cents
3041
+ // clientSecret: 'pi_xxx_secret_xxx', // See the note below — NOT always a secret
3042
+ // amount: '99.99', // DECIMAL STRING, not cents. parseFloat() it.
2938
3043
  // currency: 'USD',
2939
- // status: 'requires_payment_method'
3044
+ // status: 'requires_payment_method',
3045
+ // provider: 'stripe', // which processor took the intent
3046
+ // clientSdk: { renderType: 'sdk-widget', /* … */ }, // HOW to render — see below
2940
3047
  // }
2941
3048
  ```
2942
3049
 
3050
+ > **`amount` is a decimal string in the charged currency (`"99.99"`), never an
3051
+ > integer of cents.** `parseFloat()` it before any math; do not divide by 100.
3052
+
3053
+ **`provider` and `clientSdk` are the two fields that decide what you render**, and
3054
+ both are omitted from most copy-paste snippets. `clientSdk.renderType` is one of
3055
+ `'sdk-widget' | 'iframe' | 'redirect' | 'sandbox' | 'embedded-fields'`:
3056
+
3057
+ | `renderType` | What `clientSecret` holds | What to do |
3058
+ | -------------- | ------------------------- | ------------------------------------------------------------------------------------------------ |
3059
+ | `'sandbox'` | (unused) | No payment UI — complete the checkout directly |
3060
+ | `'sdk-widget'` | Client secret / auth code | Load `clientSdk.scriptUrl`, init with `clientSdk.initConfig`, mount into `clientSdk.containerId` |
3061
+ | `'iframe'` | **A URL** | Load it in an iframe (inline if its path contains `/embed/`, else in a modal) |
3062
+ | `'redirect'` | **A URL** | Navigate the top-level window to it; on return call `confirmSdkPayment()` |
3063
+
3064
+ Branch on `clientSdk?.renderType`. **Never** branch on "does `clientSdk` exist" —
3065
+ every provider returns one, sandbox included — and never hard-code by provider name.
3066
+ Only `provider === 'stripe'` has a `clientSdk.initConfig.publishableKey`.
3067
+
3068
+ #### Confirm an SDK / redirect payment
3069
+
3070
+ ```typescript
3071
+ // confirmSdkPayment(checkoutId, providerResponseData?) => Promise<{ confirmed: boolean }>
3072
+ await client.confirmSdkPayment(checkoutId);
3073
+
3074
+ // With the provider's own callback payload (transactionId, transactionToken,
3075
+ // confirmation_number, …) when its SDK hands you one:
3076
+ await client.confirmSdkPayment(checkoutId, { transactionId: 'txn_123' });
3077
+ ```
3078
+
3079
+ Call it in two places:
3080
+
3081
+ - **In an in-page SDK's success callback** (`renderType: 'sdk-widget'`) — it tells
3082
+ the backend the payment succeeded, which triggers order creation.
3083
+ - **On the return page from a `renderType: 'redirect'` provider** — redirect
3084
+ providers don't capture until the server confirms, so this is what makes the
3085
+ backend verify with the provider and capture.
3086
+
3087
+ It is **idempotent** (safe if a webhook already captured) and safe to skip on
3088
+ failure — `getPaymentStatus()` / `waitForOrder()` re-verify server-side. Wrap it in
3089
+ `try/catch` and carry on:
3090
+
3091
+ ```typescript
3092
+ try {
3093
+ await client.confirmSdkPayment(checkoutId);
3094
+ } catch {
3095
+ // Not fatal — polling re-verifies.
3096
+ }
3097
+ const result = await client.waitForOrder(checkoutId);
3098
+ ```
3099
+
3100
+ Do **not** call it on your `cancelUrl` — the buyer abandoned; just let them retry.
3101
+
3102
+ `confirmGrowPayment()` is a deprecated wrapper around this method; call
3103
+ `confirmSdkPayment()` directly.
3104
+
3105
+ > `confirmSdkPayment()`, `getPaymentStatus()`, `createPaymentIntent()`,
3106
+ > `getPaymentProviders()` and `waitForOrder()` are **sales-channel mode only** —
3107
+ > they throw `BrainerceError` 400 on a `storeId` or `apiKey` client.
3108
+
2943
3109
  **Routing to a specific provider (`providerId`).** With `getPaymentProviders()` you
2944
3110
  render additive **express buttons** (e.g. PayPal as a `WALLET`) alongside the primary
2945
3111
  card form. When the buyer taps one, pass that provider's `id` so the charge routes to
@@ -2964,7 +3130,10 @@ selectable.
2964
3130
  Use the client secret with Stripe.js to collect payment:
2965
3131
 
2966
3132
  ```typescript
2967
- // Initialize Stripe.js with the public key from getPaymentConfig()
3133
+ // Initialize Stripe.js with the publishable key. Prefer
3134
+ // intent.clientSdk.initConfig.publishableKey (per-intent, correct for the
3135
+ // provider that actually took the charge); config.publicKey is the deprecated
3136
+ // getPaymentConfig() fallback.
2968
3137
  const stripe = await loadStripe(config.publicKey);
2969
3138
 
2970
3139
  // Create Elements and Payment Element
@@ -3121,7 +3290,10 @@ export default function CheckoutPaymentPage() {
3121
3290
  const checkoutId = new URLSearchParams(window.location.search).get('checkout_id');
3122
3291
  if (!checkoutId) throw new Error('No checkout ID');
3123
3292
 
3124
- // Get payment configuration
3293
+ // Get payment configuration.
3294
+ // NOTE: getPaymentConfig() is @deprecated — single-provider only. In new
3295
+ // code use getPaymentProviders() and render additive express methods
3296
+ // alongside the primary card form. Kept here as a minimal Stripe example.
3125
3297
  const config = await client.getPaymentConfig();
3126
3298
  setPaymentConfig(config);
3127
3299
 
@@ -3296,10 +3468,23 @@ if (intent.clientSdk?.renderType === 'sandbox') {
3296
3468
 
3297
3469
  #### Register Customer
3298
3470
 
3471
+ > **Password policy — enforced on `registerCustomer()` AND `resetPassword()`.**
3472
+ > At least **8 characters**, with at least one **lowercase** letter, one
3473
+ > **uppercase** letter, one **digit**, and one **special character**
3474
+ > (`/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^a-zA-Z0-9]).{8,}$/`). A password that
3475
+ > fails returns HTTP 400 with the message _"Password must contain at least 1
3476
+ > uppercase letter, 1 lowercase letter, 1 number, and 1 special character"_.
3477
+ >
3478
+ > `securepassword123` fails (no uppercase, no special). `Password123` fails (no
3479
+ > special). `SecurePass123!` passes. Mirror the full rule in your own client-side
3480
+ > validation and in the field's helper text — a form that only says "min 8
3481
+ > characters" produces a 400 the shopper cannot explain, and render the server's
3482
+ > message verbatim when one comes back.
3483
+
3299
3484
  ```typescript
3300
3485
  const auth = await client.registerCustomer({
3301
3486
  email: 'customer@example.com',
3302
- password: 'securepassword123',
3487
+ password: 'SecurePass123!',
3303
3488
  firstName: 'John',
3304
3489
  lastName: 'Doe',
3305
3490
  });
@@ -3309,7 +3494,8 @@ if (auth.requiresVerification) {
3309
3494
  localStorage.setItem('verificationToken', auth.token);
3310
3495
  window.location.href = '/verify-email';
3311
3496
  } else {
3312
- setCustomerToken(auth.token);
3497
+ client.setCustomerToken(auth.token);
3498
+ await client.syncCartOnLogin(); // REQUIRED — claims the guest cart onto the account
3313
3499
  // Redirect back to store, not /account
3314
3500
  window.location.href = '/';
3315
3501
  }
@@ -3318,8 +3504,9 @@ if (auth.requiresVerification) {
3318
3504
  #### Login Customer
3319
3505
 
3320
3506
  ```typescript
3321
- const auth = await client.loginCustomer('customer@example.com', 'password123');
3322
- setCustomerToken(auth.token);
3507
+ const auth = await client.loginCustomer('customer@example.com', 'SecurePass123!');
3508
+ client.setCustomerToken(auth.token);
3509
+ await client.syncCartOnLogin(); // REQUIRED — claims the guest cart onto the account
3323
3510
 
3324
3511
  // Best practice: redirect back to previous page or home
3325
3512
  const returnUrl = localStorage.getItem('returnUrl') || '/';
@@ -3327,6 +3514,13 @@ localStorage.removeItem('returnUrl');
3327
3514
  window.location.href = returnUrl;
3328
3515
  ```
3329
3516
 
3517
+ > **`setCustomerToken()` is a plain field setter — it does not touch the cart.**
3518
+ > Always follow it with `await client.syncCartOnLogin()`. Skip it and the
3519
+ > shopper's guest cart is never attached to their account, which quietly breaks
3520
+ > every identity-keyed feature: first-order discounts, per-customer coupon caps,
3521
+ > and abandoned-cart recovery. Same rule after `verifyEmail()` and after
3522
+ > `exchangeOAuthCode()`.
3523
+
3330
3524
  > **Best Practice:** Before showing login page, save the current URL with `localStorage.setItem('returnUrl', window.location.pathname)`. After login, redirect back to that URL. This is how Amazon, Shopify, and most e-commerce sites work.
3331
3525
 
3332
3526
  #### Forgot Password
@@ -3342,7 +3536,7 @@ await client.forgotPassword('customer@example.com');
3342
3536
  ```typescript
3343
3537
  // On /reset-password page, extract token from URL
3344
3538
  const token = new URLSearchParams(window.location.search).get('token');
3345
- const result = await client.resetPassword(token!, 'newSecurePassword123');
3539
+ const result = await client.resetPassword(token!, 'NewSecurePass123!');
3346
3540
  // result.message = "Password has been reset successfully"
3347
3541
  ```
3348
3542
 
@@ -3545,7 +3739,7 @@ When `requiresVerification` is true in the registration response, the customer n
3545
3739
  ```typescript
3546
3740
  const auth = await client.registerCustomer({
3547
3741
  email: 'customer@example.com',
3548
- password: 'securepassword123',
3742
+ password: 'SecurePass123!',
3549
3743
  firstName: 'John',
3550
3744
  });
3551
3745
 
@@ -4285,6 +4479,118 @@ await client.bulkSaveVariants(variableProduct.id, {
4285
4479
 
4286
4480
  **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.
4287
4481
 
4482
+ ### Bulk Product Creation (catalog import)
4483
+
4484
+ Importing a catalog — a supplier feed, a CSV/Excel export, a store migration —
4485
+ should not be thousands of `createProduct` calls. `bulkCreateProducts` takes an
4486
+ array and returns a **job id**: the work is queued, and the products appear over
4487
+ the following seconds or minutes.
4488
+
4489
+ ```typescript
4490
+ import type {
4491
+ BulkCreateProductsDto,
4492
+ BulkCreateProductsJob,
4493
+ BulkCreateProductsStatus,
4494
+ } from 'brainerce';
4495
+
4496
+ const job: BulkCreateProductsJob = await client.bulkCreateProducts({
4497
+ products: [
4498
+ {
4499
+ name: 'Classic T-Shirt',
4500
+ sku: 'TSH-001',
4501
+ externalId: 'supplier-88213', // your source-system id — makes retries safe
4502
+ basePrice: 29.99,
4503
+ type: 'SIMPLE',
4504
+ categoryNames: ['Apparel'], // auto-created if missing
4505
+ brandNames: ['Acme'],
4506
+ tags: ['summer'],
4507
+ inventory: { total: 100 },
4508
+ },
4509
+ // ...up to 1000 per call
4510
+ ],
4511
+ importId: 'supplier-catalog-2026-08-21', // ties chunks of one import together
4512
+ conflictStrategy: 'skip', // 'skip' (default) | 'error'
4513
+ });
4514
+
4515
+ console.log(job.jobId, job.total); // the import has STARTED, not finished
4516
+ ```
4517
+
4518
+ **This call does not return the created products.** Poll for progress:
4519
+
4520
+ ```typescript
4521
+ let status: BulkCreateProductsStatus = await client.getBulkCreateProductsStatus(job.jobId);
4522
+ while (status.status === 'QUEUED' || status.status === 'RUNNING') {
4523
+ await new Promise((r) => setTimeout(r, 2000));
4524
+ status = await client.getBulkCreateProductsStatus(job.jobId);
4525
+ }
4526
+
4527
+ // succeeded = created. skipped = already existed, NOT created.
4528
+ // "succeeded + skipped" is not the number of products you imported.
4529
+ console.log(status.succeeded, status.skipped, status.failed, status.pending);
4530
+ ```
4531
+
4532
+ `COMPLETED_WITH_ERRORS` means the import finished and some rows failed — there
4533
+ is nothing to re-run. Read the failures instead; each carries the 1-indexed
4534
+ `row` from the array you submitted, so it maps back to the line of the source
4535
+ spreadsheet:
4536
+
4537
+ ```typescript
4538
+ if (status.failed > 0) {
4539
+ const { data, meta } = await client.getBulkCreateProductsErrors(job.jobId, { limit: 100 });
4540
+ for (const e of data) {
4541
+ console.log(`row ${e.row} (${e.sku ?? e.productName}): [${e.code}] ${e.message}`);
4542
+ }
4543
+ // Every failure is stored — nothing is truncated — so walk the pages.
4544
+ console.log(`${meta.total} failures across ${meta.totalPages} pages`);
4545
+ }
4546
+ ```
4547
+
4548
+ **Importing 3,000-50,000 products.** A single request is capped at 1000 rows
4549
+ (500 is the comfortable size) because validating a huge nested array costs real
4550
+ CPU in the request handler. Chunk the catalog and pass the same `importId` on
4551
+ every call, then poll **once** for the whole import:
4552
+
4553
+ ```typescript
4554
+ const importId = `migration-${Date.now()}`;
4555
+ for (const chunk of chunks(allProducts, 500)) {
4556
+ await client.bulkCreateProducts({
4557
+ products: chunk,
4558
+ importId,
4559
+ idempotencyKey: `${importId}-${chunkIndex}`, // re-sending returns the original job
4560
+ });
4561
+ }
4562
+
4563
+ const overall = await client.getBulkCreateProductsImportStatus(importId);
4564
+ console.log(`${overall.processed}/${overall.total}`, overall.status);
4565
+ ```
4566
+
4567
+ `getBulkCreateProductsImportStatus` reports the least-complete state across the
4568
+ chunks and leaves `finishedAt` null until every one has finished, so a partial
4569
+ result can never read as a finished import.
4570
+
4571
+ **Duplicates.** A row whose `sku` or `externalId` already exists in the store is
4572
+ skipped rather than duplicated — so a batch re-sent after a timeout cannot
4573
+ create the catalog twice. This is a database-level check, so it still holds days
4574
+ later and across retries. Rows carrying **neither** a `sku` nor an `externalId`
4575
+ have nothing to match on and will be created again on a re-send; set
4576
+ `externalId` on rows without SKUs. Pass `conflictStrategy: 'error'` when a
4577
+ duplicate means the source file is wrong and you want it reported rather than
4578
+ skipped.
4579
+
4580
+ **One difference from `createProduct`.** If a row's explicit `slug` is already
4581
+ taken, the importer appends a suffix (`t-shirt`, `t-shirt-1`, ...) and imports
4582
+ the row, where `createProduct` returns a 400. Rows colliding with each other
4583
+ inside the same batch are resolved the same way, in submission order. That is
4584
+ deliberate — a spreadsheet with two "T-Shirt" rows should import, not fail — but
4585
+ it means the slug you sent is not always the slug you get. Read it back from
4586
+ the product if you depend on it.
4587
+
4588
+ **Channel sync.** By default (`syncMode: 'coalesced'`) the per-product push to
4589
+ connected sales channels is suppressed during the import and one sync per
4590
+ affected channel is filed at the end — connectors are rate-limited per catalog,
4591
+ and a per-product fan-out would exhaust those limits. Use `syncMode: 'none'` to
4592
+ write to Brainerce only.
4593
+
4288
4594
  ### Taxonomy Management
4289
4595
 
4290
4596
  ```typescript
@@ -4373,11 +4679,9 @@ await client.createAttributeOption(sizeAttr.id, {
4373
4679
  source: 'GLOBAL',
4374
4680
  });
4375
4681
 
4376
- // Get options, update, delete
4682
+ // Get options, update the attribute, delete the attribute
4377
4683
  const options = await client.getAttributeOptions(colorAttr.id);
4378
4684
  await client.updateAttribute(colorAttr.id, { displayType: 'IMAGE_SWATCH' });
4379
- await client.updateAttributeOption(colorAttr.id, options[0].id, { swatchColor: '#CC0000' });
4380
- await client.deleteAttributeOption(colorAttr.id, options[0].id);
4381
4685
  await client.deleteAttribute(colorAttr.id);
4382
4686
 
4383
4687
  // Storefront: render swatches from product data
@@ -4388,6 +4692,15 @@ const swatches = getProductSwatches(product);
4388
4692
  // Returns: [{ attributeName: 'Color', displayType: 'COLOR_SWATCH', options: [{ name: 'Red', swatchColor: '#FF0000', ... }] }]
4389
4693
  ```
4390
4694
 
4695
+ > **Editing or deleting a single option is dashboard-only.** `/api/v1/attributes/:id/options`
4696
+ > exposes exactly two verbs — `GET` (list) and `POST` (add). There is no per-option route
4697
+ > on the public API, so `client.updateAttributeOption()` and `client.deleteAttributeOption()`
4698
+ > return **404**. The per-option routes exist only on the dashboard API
4699
+ > (`PUT` / `DELETE /api/stores/:storeId/attributes/:id/options/:optionId`), behind Clerk
4700
+ > auth. Recolour a swatch or drop an option in the dashboard. Everything else on
4701
+ > attributes — create, list, `updateAttribute`, `deleteAttribute`, `getAttributeOptions`,
4702
+ > `createAttributeOption` — works from the SDK as shown above.
4703
+
4391
4704
  ### Shipping Configuration
4392
4705
 
4393
4706
  ```typescript
@@ -4723,8 +5036,9 @@ vibe-coded site **only if** it has been explicitly published to that
4723
5036
  connection. Entities with no publish rows are invisible to every vibe-coded
4724
5037
  site, including in product responses (the related categories/brands/tags
4725
5038
  arrays and the metafields array on each product are filtered the same way).
4726
- Merchants publish through the dashboard's per-row Platforms cell or via the
4727
- admin SDK below.
5039
+ Merchants publish through the dashboard's per-row Platforms cell; for
5040
+ categories, tags and brands the admin SDK below does the same thing. Custom
5041
+ fields are the exception — see the note under the snippet.
4728
5042
 
4729
5043
  ```typescript
4730
5044
  // Publish a product to a sales channel (accepts record ID or vc_* connection ID)
@@ -4734,24 +5048,38 @@ await client.unpublishProductFromSalesChannel('prod_id', 'vc_conn_id');
4734
5048
 
4735
5049
  // Publish a category to a specific vibe-coded site
4736
5050
  await client.publishCategoryToVibeCodedSite('cat_id', 'conn_id');
4737
- // Tag, brand, metafield definition follow the same shape:
5051
+ // Tag and brand follow the same shape:
4738
5052
  await client.publishTagToVibeCodedSite('tag_id', 'conn_id');
4739
5053
  await client.publishBrandToVibeCodedSite('brand_id', 'conn_id');
4740
- await client.publishMetafieldDefinitionToVibeCodedSite('def_id', 'conn_id');
4741
5054
 
4742
5055
  // Unpublish — entity is no longer visible to that site (but stays visible
4743
5056
  // to every other site if no other publishes exist).
4744
5057
  await client.unpublishCategoryFromVibeCodedSite('cat_id', 'conn_id');
4745
5058
  await client.unpublishTagFromVibeCodedSite('tag_id', 'conn_id');
4746
5059
  await client.unpublishBrandFromVibeCodedSite('brand_id', 'conn_id');
4747
- await client.unpublishMetafieldDefinitionFromVibeCodedSite('def_id', 'conn_id');
4748
5060
 
4749
5061
  // Read which sites an entity is published to via the admin list/get response —
4750
- // `vibeCodedPublishes` is included on every list/get for these 4 entity types:
5062
+ // `channelPublishes` is included on every list/get for these 4 entity types:
4751
5063
  const cat = await client.getCategory('cat_id');
4752
- cat.vibeCodedPublishes; // [{ connection: { id, name, connectionId } }, ...]
5064
+ cat.channelPublishes; // [{ salesChannel: { id, name, connectionId } }, ...]
4753
5065
  ```
4754
5066
 
5067
+ > **Custom fields are the exception — publish them in the dashboard.**
5068
+ > `client.publishMetafieldDefinitionToVibeCodedSite()` and
5069
+ > `client.unpublishMetafieldDefinitionFromVibeCodedSite()` target
5070
+ > `/api/v1/metafield-definitions/:id/publish-vibe-coded`, which the public API does not
5071
+ > expose; both return **404**. Only the dashboard API carries those routes
5072
+ > (`POST /api/stores/:storeId/metafield-definitions/:id/publish` and `…/unpublish`),
5073
+ > behind Clerk auth. **Reading is unaffected** — `getMetafieldDefinitions()` and
5074
+ > `getMetafieldDefinition()` both return `channelPublishes` exactly like the other three
5075
+ > entity types, so you can still see which sites a custom field is published to; you just
5076
+ > cannot change it from the SDK.
5077
+
5078
+ > **`vibeCodedPublishes` and its `connection` sub-key are deprecated.** Both are
5079
+ > still emitted as back-compat aliases of `channelPublishes` / `salesChannel` and
5080
+ > are removed in SDK 2.0. Read `channelPublishes[].salesChannel` in new code —
5081
+ > the customer section below already does.
5082
+
4755
5083
  **Cross-account isolation:** publishing only succeeds when the entity and the
4756
5084
  target vibe-coded connection both belong to the same account. Cross-account
4757
5085
  calls fail with `404 Not Found` (the connection ID is treated as if it doesn't
@@ -4797,48 +5125,33 @@ not prevent that person from buying on that storefront, and the row comes back
4797
5125
  the next time they sign in or order there. There is no API to bar a customer
4798
5126
  from a sales channel.
4799
5127
 
4800
- ### Store Team Management
4801
-
4802
- Each store has its own team with roles (`OWNER`, `MANAGER`, `STAFF`, `VIEWER`) and granular permissions.
4803
-
4804
- ```typescript
4805
- // List team members + pending invitations for a store
4806
- const { members, invitations } = await client.getStoreTeam('store_id');
4807
-
4808
- // Invite a new member
4809
- const invitation = await client.inviteStoreMember('store_id', {
4810
- email: 'newmember@example.com',
4811
- role: 'MANAGER', // 'MANAGER' | 'STAFF' | 'VIEWER'
4812
- salesChannelIds: ['vc_abc123'], // optional: restrict to vibe-coded channels (connectionId); omit/[] = all channels
4813
- });
4814
-
4815
- // Update member role or set custom permissions
4816
- await client.updateStoreMember('store_id', 'member_id', { role: 'STAFF' });
4817
- await client.updateStoreMember('store_id', 'member_id', {
4818
- permissions: ['VIEW_PRODUCTS', 'VIEW_ORDERS', 'FULFILL_ORDERS'], // overrides role defaults
4819
- });
4820
-
4821
- // Replace a member's vibe-coded sales-channel scope ([] clears all restrictions)
4822
- await client.updateStoreMemberSalesChannels('store_id', 'member_id', {
4823
- salesChannelIds: ['vc_abc123', 'vc_def456'],
4824
- });
4825
-
4826
- // Remove a member
4827
- await client.removeStoreMember('store_id', 'member_id');
4828
-
4829
- // Manage invitations
4830
- await client.resendStoreInvitation('store_id', 'inv_id');
4831
- await client.revokeStoreInvitation('store_id', 'inv_id');
4832
-
4833
- // Get all stores accessible to the current user (owned + shared)
4834
- const stores = await client.getMyStores();
4835
- // [{ id, name, role: 'OWNER', context: 'owner' }, { id, name, role: 'MANAGER', context: 'member' }]
4836
-
4837
- // Get user's permissions for a specific store
4838
- const { role, permissions } = await client.getMyStorePermissions('store_id');
4839
- ```
4840
-
4841
- > **Note:** The previous account-level team methods (`getTeamMembers`, `inviteTeamMember`, etc.) are deprecated. Use the store-level methods above instead.
5128
+ ### Store Team Management — dashboard-only
5129
+
5130
+ Each store has its own team with roles (`OWNER`, `MANAGER`, `STAFF`, `VIEWER`) and
5131
+ granular permissions, including per-sales-channel scoping. **Managing it is a dashboard
5132
+ operation. There is no SDK path to it, and this is deliberate.**
5133
+
5134
+ This section previously showed nine store-level team calls. They do not work:
5135
+ `getStoreTeam`, `inviteStoreMember`, `updateStoreMember`, `updateStoreMemberSalesChannels`,
5136
+ `removeStoreMember`, `resendStoreInvitation` and `revokeStoreInvitation` call
5137
+ `/api/v1/stores/:storeId/team…`, and `getMyStores` / `getMyStorePermissions` call
5138
+ `/api/v1/me/stores…`. The public API exposes neither prefix, so every one of them returns
5139
+ **404**.
5140
+
5141
+ **This is not a path typo waiting on a fix.** The real endpoints exist at
5142
+ `/api/stores/:storeId/team…`, guarded by `DashboardOnlyGuard`, which rejects API-key
5143
+ principals _by design_: an API key carries a `storeId` but never a `userId`, and the
5144
+ team service resolves a missing `userId` to an OWNER role. Failing closed at the boundary
5145
+ is what stops an API key from escalating its own team permissions. Pointing the SDK at
5146
+ the correct path would earn a `403` instead of a `404`. Invite, re-scope and remove
5147
+ members in the dashboard.
5148
+
5149
+ > **The older account-level methods are not the workaround.** `getTeamMembers`,
5150
+ > `getTeamInvitations`, `inviteTeamMember`, `resendTeamInvitation`, `revokeTeamInvitation`,
5151
+ > `updateTeamMemberRole` and `removeTeamMember` do still reach `/api/v1/team/…` — but they
5152
+ > manage the **account** team, not a store's, and all seven are `@deprecated`. Their JSDoc
5153
+ > tells you to migrate to the store-level methods named above; ignore that advice, because
5154
+ > those methods 404. Don't build new integrations on either family.
4842
5155
 
4843
5156
  ### Email Settings & Templates
4844
5157
 
@@ -4908,10 +5221,6 @@ const { summarized, summary } = await client.summarizeBotConversation(conversati
4908
5221
  ### Sync Conflict Resolution
4909
5222
 
4910
5223
  ```typescript
4911
- // Product sync conflicts
4912
- const conflicts = await client.getSyncConflicts();
4913
- await client.resolveSyncConflict('conflict_id', 'MERGE'); // or 'CREATE_NEW'
4914
-
4915
5224
  // Metafield conflicts
4916
5225
  const metafieldConflicts = await client.getMetafieldConflicts();
4917
5226
  await client.resolveMetafieldConflict('conflict_id', {
@@ -4920,6 +5229,12 @@ await client.resolveMetafieldConflict('conflict_id', {
4920
5229
  await client.ignoreMetafieldConflict('conflict_id');
4921
5230
  ```
4922
5231
 
5232
+ > **Product sync conflicts are dashboard-only.** `client.getSyncConflicts()` and
5233
+ > `client.resolveSyncConflict()` call `/api/v1/sync-conflicts`, which the public API does
5234
+ > not expose; both return **404**. Review a product conflict and pick `MERGE` or
5235
+ > `CREATE_NEW` in the dashboard. The three metafield-conflict methods above are a separate
5236
+ > surface (`/api/v1/metafield-conflicts`) and do work with an API key.
5237
+
4923
5238
  ### OAuth Provider Configuration
4924
5239
 
4925
5240
  ```typescript
@@ -4937,7 +5252,7 @@ await client.deleteOAuthProvider('GOOGLE');
4937
5252
 
4938
5253
  ### Modifier Groups Management
4939
5254
 
4940
- 12 admin methods covering group / modifier / attachment CRUD. Money fields are decimal strings on the wire (`priceDelta: "5.00"`). Server-side validation failures arrive as a `MODIFIER_VALIDATION_FAILED` envelope on `BrainerceError.details`.
5255
+ 12 admin methods covering group / modifier / attachment CRUD. Money fields are decimal strings on the wire (`priceDelta: "5.00"`). Server-side validation failures arrive as a `MODIFIER_VALIDATION_FAILED` envelope on `BrainerceError.details`, with the per-issue list nested at `err.details.details.errors`.
4941
5256
 
4942
5257
  ```typescript
4943
5258
  // Group CRUD
@@ -5798,7 +6113,9 @@ export default function RegisterPage() {
5798
6113
  <input placeholder="Last Name" value={lastName} onChange={e => setLastName(e.target.value)} required className="border p-2 rounded" />
5799
6114
  </div>
5800
6115
  <input type="email" placeholder="Email" value={email} onChange={e => setEmail(e.target.value)} required className="w-full border p-2 rounded" />
5801
- <input type="password" placeholder="Password (min 8 characters)" value={password} onChange={e => setPassword(e.target.value)} required minLength={8} className="w-full border p-2 rounded" />
6116
+ {/* The API rejects anything weaker: 8+ chars with a lowercase, an uppercase,
6117
+ a digit AND a special character. `minLength={8}` alone lets a 400 through. */}
6118
+ <input type="password" placeholder="8+ chars, upper + lower + number + symbol" value={password} onChange={e => setPassword(e.target.value)} required minLength={8} pattern="(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^a-zA-Z0-9]).{8,}" title="At least 8 characters with one uppercase letter, one lowercase letter, one number and one special character" className="w-full border p-2 rounded" />
5802
6119
  <button type="submit" disabled={loading} className="w-full bg-black text-white py-3 rounded">
5803
6120
  {loading ? 'Creating Account...' : 'Create Account'}
5804
6121
  </button>
@@ -6021,6 +6338,8 @@ The widget persists an anonymous session in `localStorage`, restores conversatio
6021
6338
 
6022
6339
  **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.
6023
6340
 
6341
+ **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.
6342
+
6024
6343
  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`).
6025
6344
 
6026
6345
  ## Webhooks
@@ -6220,7 +6539,8 @@ const handleAddToCart = async (productId: string, quantity: number) => {
6220
6539
  }
6221
6540
  };
6222
6541
 
6223
- // Checkout with toast feedback
6542
+ // Checkout with toast feedback (cash-on-delivery / sandbox store — a store that
6543
+ // takes payment must run the checkout + payment-intent flow instead)
6224
6544
  const handleCheckout = async () => {
6225
6545
  try {
6226
6546
  const order = await client.submitGuestOrder();
@@ -6296,6 +6616,7 @@ export function useBrainerceAction<T>() {
6296
6616
  const { execute, isLoading } = useBrainerceAction();
6297
6617
 
6298
6618
  const handlePlaceOrder = () => {
6619
+ // Cash-on-delivery / sandbox only — submitGuestOrder() collects no payment.
6299
6620
  execute(() => client.submitGuestOrder(), {
6300
6621
  successMessage: 'Order placed successfully!',
6301
6622
  onSuccess: (order) => navigate(`/order/${order.orderId}`),