brainerce 1.58.1 → 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
@@ -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
 
@@ -1072,6 +1091,9 @@ if (!address.country) {
1072
1091
  // guaranteed) the same ISO 3166-2 subdivision code this store's own region
1073
1092
  // lists use. Validate against destinations.regions before trusting it;
1074
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.
1075
1097
  const destinations = await client.getShippingDestinations();
1076
1098
  const validRegions = destinations.regions[address.country] ?? [];
1077
1099
  const region = validRegions.some((r) => r.code === address.region) ? address.region : '';
@@ -1666,27 +1688,58 @@ function SearchInput() {
1666
1688
 
1667
1689
  #### Product Type Definition
1668
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
+
1669
1696
  ```typescript
1670
1697
  interface Product {
1671
1698
  id: string;
1672
1699
  name: string;
1673
- description?: string | null;
1674
- 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;
1675
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()
1676
1708
  basePrice: string; // Decimal as string — use parseFloat() for calculations
1677
1709
  salePrice?: string | null;
1678
1710
  salePriceStartsAt?: string | null; // ISO 8601 — sale-price effective window start
1679
1711
  salePriceEndsAt?: string | null; // ISO 8601 — sale-price window end; feeds buildProductJsonLd's priceValidUntil
1712
+ costPrice?: string | null; // COGS — admin (apiKey) reads only
1680
1713
  priceMin?: string | null; // Lowest variant price (VARIABLE products only)
1681
1714
  priceMax?: string | null; // Highest variant price (VARIABLE products only)
1682
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
1683
1723
  status: string; // e.g. "active" | "draft"
1684
1724
  type: 'SIMPLE' | 'VARIABLE';
1725
+ isDownloadable?: boolean;
1726
+ downloads?: DownloadFile[] | null; // when isDownloadable
1685
1727
  images?: ProductImage[];
1686
1728
  inventory?: InventoryInfo | null;
1687
1729
  variants?: ProductVariant[];
1688
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[]
1689
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
1690
1743
  createdAt: string;
1691
1744
  updatedAt: string;
1692
1745
  }
@@ -1695,28 +1748,48 @@ interface ProductImage {
1695
1748
  url: string;
1696
1749
  position?: number;
1697
1750
  isMain?: boolean;
1751
+ alt?: string;
1752
+ // admin (apiKey) mode only: id, key, thumbnailUrl, width, height, size, mimeType, createdAt
1698
1753
  }
1699
1754
 
1700
1755
  interface ProductVariant {
1701
1756
  id: string;
1757
+ productId: string; // REQUIRED — parent product
1702
1758
  sku?: string | null;
1703
1759
  name?: string | null;
1704
- price?: number | null;
1705
- salePrice?: number | null;
1706
- 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()
1707
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
1708
1775
  }
1709
1776
 
1710
1777
  interface InventoryInfo {
1711
1778
  total: number;
1712
1779
  reserved: number;
1713
1780
  available: number;
1714
- trackingMode?: 'TRACKED' | 'UNLIMITED' | 'DISABLED';
1781
+ trackingMode: InventoryTrackingMode; // REQUIRED — 'TRACKED' | 'UNLIMITED' | 'DISABLED'
1715
1782
  inStock: boolean; // Pre-calculated - use this for display!
1716
1783
  canPurchase: boolean; // Pre-calculated - use this for add-to-cart
1784
+ lastInventorySyncAt?: string | null; // admin mode only
1717
1785
  }
1718
1786
  ```
1719
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
+
1720
1793
  #### Product Metafields (Custom Fields)
1721
1794
 
1722
1795
  Products can have custom fields (metafields) defined by the store owner, such as "Material", "Care Instructions", or "Warranty".
@@ -1907,15 +1980,20 @@ await client.addToCart(cartId, {
1907
1980
  | GALLERY | `string[]` (URLs) | Multi-file upload |
1908
1981
  | DIMENSION/WEIGHT | `{ value, unit }` | Value + unit inputs |
1909
1982
 
1910
- **Admin methods** (require API key):
1911
-
1912
- ```typescript
1913
- // Set which customer-input definitions apply to a product
1914
- await client.setProductCustomizationFields(productId, [definitionId1, definitionId2]);
1915
-
1916
- // Get current assignments
1917
- const fields = await client.getProductCustomizationFields(productId);
1918
- ```
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.
1919
1997
 
1920
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`.
1921
1999
 
@@ -2117,9 +2195,17 @@ const totals = getCartTotals(cart);
2117
2195
 
2118
2196
  ---
2119
2197
 
2120
- ### Guest Checkout (Submit Order)
2198
+ ### Guest Checkout (Submit Order) — no payment collected
2121
2199
 
2122
- > **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.
2123
2209
 
2124
2210
  ```typescript
2125
2211
  // Make sure cart has items, customer email, and shipping address
@@ -2227,7 +2313,8 @@ if (checkout.tracked) {
2227
2313
  const order = await client.completeGuestCheckout(checkout.checkoutId);
2228
2314
  console.log('Order created:', order.orderId);
2229
2315
  } else {
2230
- // 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.
2231
2318
  const order = await client.submitGuestOrder();
2232
2319
  }
2233
2320
  ```
@@ -2426,8 +2513,11 @@ await client.getProduct('prod_tshirt', { regionId: 'region_eu' });
2426
2513
  await client.getProductBySlug('blue-shirt', { regionId: 'region_eu' });
2427
2514
  ```
2428
2515
 
2429
- > **Display-only** like checkout, `regionId` here does not charge the region
2430
- > 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
2431
2521
  > **all three modes** — vibe-coded (`vc_*`/`salesChannelId`), storefront (`storeId`),
2432
2522
  > and admin (`apiKey`). The response gains `displayPrice` whenever a daily FX rate
2433
2523
  > exists for the store/region currency pair in **either** direction (the overlay
@@ -2917,16 +3007,21 @@ if (paypalProvider) {
2917
3007
  }
2918
3008
  ```
2919
3009
 
2920
- #### Get Payment Configuration (Single Provider)
3010
+ #### Get Payment Configuration (Single Provider) — DEPRECATED
2921
3011
 
2922
- 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.
2923
3016
 
2924
3017
  ```typescript
2925
3018
  const config = await client.getPaymentConfig();
2926
3019
 
2927
3020
  // Returns:
2928
3021
  // {
2929
- // 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.
2930
3025
  // publicKey: 'pk_live_xxx...', // Stripe publishable key or PayPal client ID
2931
3026
  // supportedMethods: ['card', 'ideal', 'bancontact'],
2932
3027
  // testMode: false
@@ -2943,13 +3038,74 @@ const intent = await client.createPaymentIntent(checkout.id);
2943
3038
  // Returns:
2944
3039
  // {
2945
3040
  // id: 'pi_xxx...',
2946
- // clientSecret: 'pi_xxx_secret_xxx', // Used by Stripe.js/PayPal SDK
2947
- // 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.
2948
3043
  // currency: 'USD',
2949
- // 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
2950
3047
  // }
2951
3048
  ```
2952
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
+
2953
3109
  **Routing to a specific provider (`providerId`).** With `getPaymentProviders()` you
2954
3110
  render additive **express buttons** (e.g. PayPal as a `WALLET`) alongside the primary
2955
3111
  card form. When the buyer taps one, pass that provider's `id` so the charge routes to
@@ -2974,7 +3130,10 @@ selectable.
2974
3130
  Use the client secret with Stripe.js to collect payment:
2975
3131
 
2976
3132
  ```typescript
2977
- // 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.
2978
3137
  const stripe = await loadStripe(config.publicKey);
2979
3138
 
2980
3139
  // Create Elements and Payment Element
@@ -3131,7 +3290,10 @@ export default function CheckoutPaymentPage() {
3131
3290
  const checkoutId = new URLSearchParams(window.location.search).get('checkout_id');
3132
3291
  if (!checkoutId) throw new Error('No checkout ID');
3133
3292
 
3134
- // 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.
3135
3297
  const config = await client.getPaymentConfig();
3136
3298
  setPaymentConfig(config);
3137
3299
 
@@ -3306,10 +3468,23 @@ if (intent.clientSdk?.renderType === 'sandbox') {
3306
3468
 
3307
3469
  #### Register Customer
3308
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
+
3309
3484
  ```typescript
3310
3485
  const auth = await client.registerCustomer({
3311
3486
  email: 'customer@example.com',
3312
- password: 'securepassword123',
3487
+ password: 'SecurePass123!',
3313
3488
  firstName: 'John',
3314
3489
  lastName: 'Doe',
3315
3490
  });
@@ -3319,7 +3494,8 @@ if (auth.requiresVerification) {
3319
3494
  localStorage.setItem('verificationToken', auth.token);
3320
3495
  window.location.href = '/verify-email';
3321
3496
  } else {
3322
- setCustomerToken(auth.token);
3497
+ client.setCustomerToken(auth.token);
3498
+ await client.syncCartOnLogin(); // REQUIRED — claims the guest cart onto the account
3323
3499
  // Redirect back to store, not /account
3324
3500
  window.location.href = '/';
3325
3501
  }
@@ -3328,8 +3504,9 @@ if (auth.requiresVerification) {
3328
3504
  #### Login Customer
3329
3505
 
3330
3506
  ```typescript
3331
- const auth = await client.loginCustomer('customer@example.com', 'password123');
3332
- 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
3333
3510
 
3334
3511
  // Best practice: redirect back to previous page or home
3335
3512
  const returnUrl = localStorage.getItem('returnUrl') || '/';
@@ -3337,6 +3514,13 @@ localStorage.removeItem('returnUrl');
3337
3514
  window.location.href = returnUrl;
3338
3515
  ```
3339
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
+
3340
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.
3341
3525
 
3342
3526
  #### Forgot Password
@@ -3352,7 +3536,7 @@ await client.forgotPassword('customer@example.com');
3352
3536
  ```typescript
3353
3537
  // On /reset-password page, extract token from URL
3354
3538
  const token = new URLSearchParams(window.location.search).get('token');
3355
- const result = await client.resetPassword(token!, 'newSecurePassword123');
3539
+ const result = await client.resetPassword(token!, 'NewSecurePass123!');
3356
3540
  // result.message = "Password has been reset successfully"
3357
3541
  ```
3358
3542
 
@@ -3555,7 +3739,7 @@ When `requiresVerification` is true in the registration response, the customer n
3555
3739
  ```typescript
3556
3740
  const auth = await client.registerCustomer({
3557
3741
  email: 'customer@example.com',
3558
- password: 'securepassword123',
3742
+ password: 'SecurePass123!',
3559
3743
  firstName: 'John',
3560
3744
  });
3561
3745
 
@@ -4495,11 +4679,9 @@ await client.createAttributeOption(sizeAttr.id, {
4495
4679
  source: 'GLOBAL',
4496
4680
  });
4497
4681
 
4498
- // Get options, update, delete
4682
+ // Get options, update the attribute, delete the attribute
4499
4683
  const options = await client.getAttributeOptions(colorAttr.id);
4500
4684
  await client.updateAttribute(colorAttr.id, { displayType: 'IMAGE_SWATCH' });
4501
- await client.updateAttributeOption(colorAttr.id, options[0].id, { swatchColor: '#CC0000' });
4502
- await client.deleteAttributeOption(colorAttr.id, options[0].id);
4503
4685
  await client.deleteAttribute(colorAttr.id);
4504
4686
 
4505
4687
  // Storefront: render swatches from product data
@@ -4510,6 +4692,15 @@ const swatches = getProductSwatches(product);
4510
4692
  // Returns: [{ attributeName: 'Color', displayType: 'COLOR_SWATCH', options: [{ name: 'Red', swatchColor: '#FF0000', ... }] }]
4511
4693
  ```
4512
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
+
4513
4704
  ### Shipping Configuration
4514
4705
 
4515
4706
  ```typescript
@@ -4845,8 +5036,9 @@ vibe-coded site **only if** it has been explicitly published to that
4845
5036
  connection. Entities with no publish rows are invisible to every vibe-coded
4846
5037
  site, including in product responses (the related categories/brands/tags
4847
5038
  arrays and the metafields array on each product are filtered the same way).
4848
- Merchants publish through the dashboard's per-row Platforms cell or via the
4849
- 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.
4850
5042
 
4851
5043
  ```typescript
4852
5044
  // Publish a product to a sales channel (accepts record ID or vc_* connection ID)
@@ -4856,24 +5048,38 @@ await client.unpublishProductFromSalesChannel('prod_id', 'vc_conn_id');
4856
5048
 
4857
5049
  // Publish a category to a specific vibe-coded site
4858
5050
  await client.publishCategoryToVibeCodedSite('cat_id', 'conn_id');
4859
- // Tag, brand, metafield definition follow the same shape:
5051
+ // Tag and brand follow the same shape:
4860
5052
  await client.publishTagToVibeCodedSite('tag_id', 'conn_id');
4861
5053
  await client.publishBrandToVibeCodedSite('brand_id', 'conn_id');
4862
- await client.publishMetafieldDefinitionToVibeCodedSite('def_id', 'conn_id');
4863
5054
 
4864
5055
  // Unpublish — entity is no longer visible to that site (but stays visible
4865
5056
  // to every other site if no other publishes exist).
4866
5057
  await client.unpublishCategoryFromVibeCodedSite('cat_id', 'conn_id');
4867
5058
  await client.unpublishTagFromVibeCodedSite('tag_id', 'conn_id');
4868
5059
  await client.unpublishBrandFromVibeCodedSite('brand_id', 'conn_id');
4869
- await client.unpublishMetafieldDefinitionFromVibeCodedSite('def_id', 'conn_id');
4870
5060
 
4871
5061
  // Read which sites an entity is published to via the admin list/get response —
4872
- // `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:
4873
5063
  const cat = await client.getCategory('cat_id');
4874
- cat.vibeCodedPublishes; // [{ connection: { id, name, connectionId } }, ...]
5064
+ cat.channelPublishes; // [{ salesChannel: { id, name, connectionId } }, ...]
4875
5065
  ```
4876
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
+
4877
5083
  **Cross-account isolation:** publishing only succeeds when the entity and the
4878
5084
  target vibe-coded connection both belong to the same account. Cross-account
4879
5085
  calls fail with `404 Not Found` (the connection ID is treated as if it doesn't
@@ -4919,48 +5125,33 @@ not prevent that person from buying on that storefront, and the row comes back
4919
5125
  the next time they sign in or order there. There is no API to bar a customer
4920
5126
  from a sales channel.
4921
5127
 
4922
- ### Store Team Management
4923
-
4924
- Each store has its own team with roles (`OWNER`, `MANAGER`, `STAFF`, `VIEWER`) and granular permissions.
4925
-
4926
- ```typescript
4927
- // List team members + pending invitations for a store
4928
- const { members, invitations } = await client.getStoreTeam('store_id');
4929
-
4930
- // Invite a new member
4931
- const invitation = await client.inviteStoreMember('store_id', {
4932
- email: 'newmember@example.com',
4933
- role: 'MANAGER', // 'MANAGER' | 'STAFF' | 'VIEWER'
4934
- salesChannelIds: ['vc_abc123'], // optional: restrict to vibe-coded channels (connectionId); omit/[] = all channels
4935
- });
4936
-
4937
- // Update member role or set custom permissions
4938
- await client.updateStoreMember('store_id', 'member_id', { role: 'STAFF' });
4939
- await client.updateStoreMember('store_id', 'member_id', {
4940
- permissions: ['VIEW_PRODUCTS', 'VIEW_ORDERS', 'FULFILL_ORDERS'], // overrides role defaults
4941
- });
4942
-
4943
- // Replace a member's vibe-coded sales-channel scope ([] clears all restrictions)
4944
- await client.updateStoreMemberSalesChannels('store_id', 'member_id', {
4945
- salesChannelIds: ['vc_abc123', 'vc_def456'],
4946
- });
4947
-
4948
- // Remove a member
4949
- await client.removeStoreMember('store_id', 'member_id');
4950
-
4951
- // Manage invitations
4952
- await client.resendStoreInvitation('store_id', 'inv_id');
4953
- await client.revokeStoreInvitation('store_id', 'inv_id');
4954
-
4955
- // Get all stores accessible to the current user (owned + shared)
4956
- const stores = await client.getMyStores();
4957
- // [{ id, name, role: 'OWNER', context: 'owner' }, { id, name, role: 'MANAGER', context: 'member' }]
4958
-
4959
- // Get user's permissions for a specific store
4960
- const { role, permissions } = await client.getMyStorePermissions('store_id');
4961
- ```
4962
-
4963
- > **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.
4964
5155
 
4965
5156
  ### Email Settings & Templates
4966
5157
 
@@ -5030,10 +5221,6 @@ const { summarized, summary } = await client.summarizeBotConversation(conversati
5030
5221
  ### Sync Conflict Resolution
5031
5222
 
5032
5223
  ```typescript
5033
- // Product sync conflicts
5034
- const conflicts = await client.getSyncConflicts();
5035
- await client.resolveSyncConflict('conflict_id', 'MERGE'); // or 'CREATE_NEW'
5036
-
5037
5224
  // Metafield conflicts
5038
5225
  const metafieldConflicts = await client.getMetafieldConflicts();
5039
5226
  await client.resolveMetafieldConflict('conflict_id', {
@@ -5042,6 +5229,12 @@ await client.resolveMetafieldConflict('conflict_id', {
5042
5229
  await client.ignoreMetafieldConflict('conflict_id');
5043
5230
  ```
5044
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
+
5045
5238
  ### OAuth Provider Configuration
5046
5239
 
5047
5240
  ```typescript
@@ -5059,7 +5252,7 @@ await client.deleteOAuthProvider('GOOGLE');
5059
5252
 
5060
5253
  ### Modifier Groups Management
5061
5254
 
5062
- 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`.
5063
5256
 
5064
5257
  ```typescript
5065
5258
  // Group CRUD
@@ -5920,7 +6113,9 @@ export default function RegisterPage() {
5920
6113
  <input placeholder="Last Name" value={lastName} onChange={e => setLastName(e.target.value)} required className="border p-2 rounded" />
5921
6114
  </div>
5922
6115
  <input type="email" placeholder="Email" value={email} onChange={e => setEmail(e.target.value)} required className="w-full border p-2 rounded" />
5923
- <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" />
5924
6119
  <button type="submit" disabled={loading} className="w-full bg-black text-white py-3 rounded">
5925
6120
  {loading ? 'Creating Account...' : 'Create Account'}
5926
6121
  </button>
@@ -6344,7 +6539,8 @@ const handleAddToCart = async (productId: string, quantity: number) => {
6344
6539
  }
6345
6540
  };
6346
6541
 
6347
- // 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)
6348
6544
  const handleCheckout = async () => {
6349
6545
  try {
6350
6546
  const order = await client.submitGuestOrder();
@@ -6420,6 +6616,7 @@ export function useBrainerceAction<T>() {
6420
6616
  const { execute, isLoading } = useBrainerceAction();
6421
6617
 
6422
6618
  const handlePlaceOrder = () => {
6619
+ // Cash-on-delivery / sandbox only — submitGuestOrder() collects no payment.
6423
6620
  execute(() => client.submitGuestOrder(), {
6424
6621
  successMessage: 'Order placed successfully!',
6425
6622
  onSuccess: (order) => navigate(`/order/${order.orderId}`),