brainerce 1.58.1 → 1.63.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
 
@@ -45,13 +62,14 @@ Every Brainerce storefront must include **all mandatory features** below. Featur
45
62
  | Login + verification branch | `client.loginCustomer()` | ✅ |
46
63
  | Forgot / reset password | `client.forgotPassword()`, `client.resetPassword()` | ✅ |
47
64
  | OAuth sign-in buttons + callback handler | `client.getAvailableOAuthProviders()` | ✅ |
48
- | Account area (profile + order history) | `client.getMyProfile()`, `client.getMyOrders()` | ✅ |
65
+ | Account area (profile + order history) | `client.getMyProfile()`, `client.updateMyProfile()`, `client.getMyOrders()` | ✅ |
49
66
  | Loyalty & rewards (points balance + tiers + redeem) | `client.getLoyaltyStatus()`, `client.getAvailableRewards()`, `client.getRecommendedReward()`, `client.redeemLoyaltyReward(id)`, `client.reportSocialShare()` | conditional |
50
67
  | Loyalty paid membership (premium subscription) | `client.getMembershipPlans()`, `client.getMySavedPaymentMethods()`, `client.subscribeToMembership(params)`, `client.cancelMembership()` | conditional |
51
68
  | Embeddable loyalty widget (points + rewards on ANY site) | `client.getLoyaltyWidgetSession()` | conditional |
52
69
  | Global header: cart count + search autocomplete | `client.getCart()`, `client.getSearchSuggestions(query)` | ✅ |
53
70
  | Discount banners + product badges | `client.getDiscountBanners()`, `client.getProductDiscountBadge(productId)` | ✅ |
54
71
  | Product reviews on PDP + JSON-LD aggregateRating | `client.listProductReviews(id)`, `client.submitProductReview(id, …)` | ✅ |
72
+ | Customer photos on reviews | `client.uploadReviewPhoto(productId, file)`, then `imageKeys` on submit | conditional |
55
73
  | Site chrome (header + footer + announcement bar) | `client.content.header.get()`, `client.content.footer.get()`, `client.content.announcement.list()` | ✅ |
56
74
  | FAQ page | `client.content.faq.get('main', locale)` | conditional |
57
75
  | Static pages catch-all (`/pages/[slug]`) | `client.content.page.getBySlug(slug, locale)` | conditional |
@@ -68,7 +86,7 @@ Violating any of these causes production incidents or broken orders. Read them b
68
86
  - ALWAYS call SDK client methods. Never reconstruct REST URLs or call `fetch` directly.
69
87
  - NEVER invent SDK method names. If it's not in this README or in `get-sdk-docs`, it doesn't exist.
70
88
  - 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.
89
+ - NEVER use `submitGuestOrder()`, `createGuestOrder()` or `createOrder()` on a store that takes payment — they `POST /orders` directly, never touch `/payment/intent`, and produce an order nobody has paid for. They exist only for cash-on-delivery, manual-invoice and sandbox stores. Everything else goes through the checkout sequence below.
72
90
  - ALWAYS use SDK helpers (`getCartTotals`, `formatPrice`, `getProductPriceInfo`, `getCartItemImage`, `getCartItemName`, `getVariantPrice`, `getStockStatus`, `getDescriptionContent`) instead of reading raw fields.
73
91
 
74
92
  ### State management
@@ -81,6 +99,7 @@ Violating any of these causes production incidents or broken orders. Read them b
81
99
 
82
100
  - ALWAYS handle the `requiresVerification` flag in `registerCustomer` and `loginCustomer` responses. If true, route to the verify-email step BEFORE treating the user as logged in.
83
101
  - ALWAYS build the verify-email, forgot-password, and reset-password flows even when the store currently has email verification disabled. They auto-hide when unused.
102
+ - ALWAYS read `requireBirthday` from `getStoreInfo()` before rendering the signup form. When it is true the merchant made the birthday mandatory on that sales channel, and `registerCustomer` returns HTTP 400 unless you send both `birthMonth` (1-12) and `birthDay` (1-31). Month and day only, never a year.
84
103
  - ALWAYS build OAuth button placeholders and a callback handler even when no OAuth provider is configured.
85
104
  - NEVER silently swallow auth errors. Render the specific error (invalid credentials, expired token, rate limited).
86
105
 
@@ -160,10 +179,12 @@ These sequences are non-negotiable. The order of SDK calls matters.
160
179
 
161
180
  ### Registration flow
162
181
 
163
- 1. Collect email, password, first name, last name.
182
+ 1. Collect email, password, first name, last name. Read `requireBirthday` from `getStoreInfo()`: when it is true, collect a birthday month and day as well, because the register call is rejected without them.
164
183
  2. Call `registerCustomer`:
165
184
  ```ts
166
185
  const result = await client.registerCustomer({ email, password, firstName, lastName });
186
+ // Channel requires a birthday? Send both fields, never a year:
187
+ // { email, password, firstName, lastName, birthMonth: 4, birthDay: 17 }
167
188
  ```
168
189
  3. Branch on `result.requiresVerification`:
169
190
  - `true` → store token temporarily, route to verify-email UI (do NOT set token yet)
@@ -419,7 +440,7 @@ await client.addToCart(cart.id, {
419
440
  });
420
441
  ```
421
442
 
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.
443
+ Money on the wire is **always strings** (`priceDelta: "5.00"`). Validation failures arrive as a structured 400 envelope on `BrainerceError.details` with `code: 'MODIFIER_VALIDATION_FAILED'`; the per-issue list is nested at `details.errors[]`, so from the SDK it reads `err.details.details.errors` (`err.details` is the whole response body) — see INTEGRATION-RULES.md "Modifier validation errors" for the full code list.
423
444
 
424
445
  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
446
 
@@ -573,14 +594,16 @@ if (result.tracked) {
573
594
  // Continue with payment flow...
574
595
  }
575
596
 
576
- // ALTERNATIVE - Use submitGuestOrder() for simple checkout without payment UI
597
+ // ⚠️ CASH-ON-DELIVERY / SANDBOX ONLY - submitGuestOrder() places the order with
598
+ // NO payment collected. Never call it on a store with a payment provider.
577
599
  const order = await client.submitGuestOrder();
578
600
  ```
579
601
 
580
602
  **Rule of thumb:**
581
603
 
582
- - Guest user + Session cart → `startGuestCheckout()` or `submitGuestOrder()`
604
+ - Guest user + Session cart → `startGuestCheckout()`
583
605
  - Logged-in user + Server cart → `createCheckout({ cartId })`
606
+ - Store that collects no money at checkout (cash on delivery, manual invoice, sandbox) → `submitGuestOrder()`
584
607
 
585
608
  ### 2. ⛔ NEVER Create Local Interfaces - Use SDK Types!
586
609
 
@@ -1072,6 +1095,9 @@ if (!address.country) {
1072
1095
  // guaranteed) the same ISO 3166-2 subdivision code this store's own region
1073
1096
  // lists use. Validate against destinations.regions before trusting it;
1074
1097
  // never assign a code the region <select> wouldn't recognize.
1098
+ // NOTE: getShippingDestinations() is CHANNEL-ONLY — it needs a `salesChannelId`
1099
+ // client. In `storeId` (public storefront) mode it targets a path the API does not
1100
+ // expose and returns 404, so drive the region <select> from your own list there.
1075
1101
  const destinations = await client.getShippingDestinations();
1076
1102
  const validRegions = destinations.regions[address.country] ?? [];
1077
1103
  const region = validRegions.some((r) => r.code === address.region) ? address.region : '';
@@ -1666,27 +1692,58 @@ function SearchInput() {
1666
1692
 
1667
1693
  #### Product Type Definition
1668
1694
 
1695
+ > **The shipped `.d.ts` is the authority.** These are abridged for reading —
1696
+ > import the real types (`import type { Product, ProductVariant } from 'brainerce'`)
1697
+ > rather than retyping them. See the Critical Rule: _never write your own copies
1698
+ > of SDK types._
1699
+
1669
1700
  ```typescript
1670
1701
  interface Product {
1671
1702
  id: string;
1672
1703
  name: string;
1673
- description?: string | null;
1674
- descriptionFormat?: 'text' | 'html' | 'markdown'; // Format of description content
1704
+ slug?: string | null; // link target for /products/{slug}
1705
+ localeSlugs?: Record<string, string> | null; // per-locale slugs for hreflang / sitemaps
1706
+ description?: string | null; // HTML — sanitize before rendering (may contain <video>/<iframe>)
1707
+ descriptionFormat?: 'text' | 'html' | 'markdown' | null;
1675
1708
  sku: string;
1709
+ gtin?: string | null; // EAN/UPC/ISBN — emitted in Product JSON-LD
1710
+ mpn?: string | null; // Manufacturer Part Number — emitted in Product JSON-LD
1711
+ faq?: Array<{ q: string; a: string }> | null; // render as a FAQ section + buildProductFaqJsonLd()
1676
1712
  basePrice: string; // Decimal as string — use parseFloat() for calculations
1677
1713
  salePrice?: string | null;
1678
1714
  salePriceStartsAt?: string | null; // ISO 8601 — sale-price effective window start
1679
1715
  salePriceEndsAt?: string | null; // ISO 8601 — sale-price window end; feeds buildProductJsonLd's priceValidUntil
1716
+ costPrice?: string | null; // COGS — admin (apiKey) reads only
1680
1717
  priceMin?: string | null; // Lowest variant price (VARIABLE products only)
1681
1718
  priceMax?: string | null; // Highest variant price (VARIABLE products only)
1682
1719
  priceVaries?: boolean; // true when range should be shown ("₪49 – ₪199")
1720
+ // Region FX overlay — present ONLY with getProducts({ regionId }). DISPLAY-ONLY;
1721
+ // basePrice/salePrice stay in the store currency. Use formatProductPrice().
1722
+ displayPrice?: string;
1723
+ displaySalePrice?: string;
1724
+ displayPriceMin?: string;
1725
+ displayPriceMax?: string;
1726
+ displayCurrency?: string; // ISO 4217 of the display* fields
1683
1727
  status: string; // e.g. "active" | "draft"
1684
1728
  type: 'SIMPLE' | 'VARIABLE';
1729
+ isDownloadable?: boolean;
1730
+ downloads?: DownloadFile[] | null; // when isDownloadable
1685
1731
  images?: ProductImage[];
1686
1732
  inventory?: InventoryInfo | null;
1687
1733
  variants?: ProductVariant[];
1688
1734
  categories?: Array<{ id: string; name: string; slug?: string | null }>; // NOT string[] — use slug to link to /category/{slug}
1735
+ brands?: Array<{ id: string; name: string }>; // objects, not string[]
1689
1736
  tags?: string[];
1737
+ metafields?: ProductMetafield[]; // custom fields — check field.type before rendering
1738
+ customizationFields?: ProductCustomizationField[]; // buyer input (engraving, uploads…)
1739
+ modifierGroups?: ModifierGroup[]; // add-ons / options priced per selection
1740
+ discount?: ProductDiscount | null; // active rule-based discount on this product
1741
+ avgRating?: number; // feeds JSON-LD aggregateRating
1742
+ reviewCount?: number;
1743
+ needsSync: boolean; // REQUIRED — always returned
1744
+ taxBehavior?: 'taxable' | 'exempt';
1745
+ menuOrder?: number | null;
1746
+ channelPublishes?: Array<{ salesChannel: { id: string; name: string; connectionId: string } }>; // admin mode only
1690
1747
  createdAt: string;
1691
1748
  updatedAt: string;
1692
1749
  }
@@ -1695,28 +1752,48 @@ interface ProductImage {
1695
1752
  url: string;
1696
1753
  position?: number;
1697
1754
  isMain?: boolean;
1755
+ alt?: string;
1756
+ // admin (apiKey) mode only: id, key, thumbnailUrl, width, height, size, mimeType, createdAt
1698
1757
  }
1699
1758
 
1700
1759
  interface ProductVariant {
1701
1760
  id: string;
1761
+ productId: string; // REQUIRED — parent product
1702
1762
  sku?: string | null;
1703
1763
  name?: string | null;
1704
- price?: number | null;
1705
- salePrice?: number | null;
1706
- attributes?: Record<string, string>;
1764
+ price?: string | null; // STRING, not number — parseFloat() before any math
1765
+ salePrice?: string | null; // STRING, not number
1766
+ costPrice?: string | null; // COGS override — admin (apiKey) reads only
1767
+ // Region FX overlay — present ONLY with getProducts({ regionId }). Display-only.
1768
+ displayPrice?: string;
1769
+ displaySalePrice?: string;
1770
+ displayCurrency?: string;
1771
+ attributes?: Record<string, string> | null; // e.g. { "Color": "Red", "Size": "M" }
1772
+ options?: Array<{ name: string; value: string }>; // alternative shape — use getVariantOptions()
1707
1773
  inventory?: InventoryInfo | null;
1774
+ image?: string | { url: string; thumbnailUrl?: string } | null;
1775
+ position: number; // REQUIRED — display order
1776
+ status?: string | null;
1777
+ createdAt: string; // REQUIRED
1778
+ updatedAt: string; // REQUIRED
1708
1779
  }
1709
1780
 
1710
1781
  interface InventoryInfo {
1711
1782
  total: number;
1712
1783
  reserved: number;
1713
1784
  available: number;
1714
- trackingMode?: 'TRACKED' | 'UNLIMITED' | 'DISABLED';
1785
+ trackingMode: InventoryTrackingMode; // REQUIRED — 'TRACKED' | 'UNLIMITED' | 'DISABLED'
1715
1786
  inStock: boolean; // Pre-calculated - use this for display!
1716
1787
  canPurchase: boolean; // Pre-calculated - use this for add-to-cart
1788
+ lastInventorySyncAt?: string | null; // admin mode only
1717
1789
  }
1718
1790
  ```
1719
1791
 
1792
+ > **Variant prices are strings.** `variant.price` and `variant.salePrice` are
1793
+ > `string | null`, exactly like `product.basePrice`. `variant.price > 100` compares
1794
+ > lexicographically and silently returns the wrong answer — always `parseFloat()`
1795
+ > first, or use `getVariantPrice(variant)` / `formatVariantPrice(variant)`.
1796
+
1720
1797
  #### Product Metafields (Custom Fields)
1721
1798
 
1722
1799
  Products can have custom fields (metafields) defined by the store owner, such as "Material", "Care Instructions", or "Warranty".
@@ -1834,7 +1911,8 @@ fields.forEach((field) => {
1834
1911
  // field.minLength, field.maxLength: validation for text fields
1835
1912
  // field.minValue, field.maxValue: validation for number fields
1836
1913
  // field.dateAvailability: constraints for DATE/DATETIME fields (blocked
1837
- // weekdays/dates, min/max date, business hours + slots) — see
1914
+ // weekdays/dates, min/max date, leadTimeMinutes/cutoffTime/maxDaysAhead,
1915
+ // business hours + slots) — see
1838
1916
  // computeAvailableSlots()/getBusinessHoursForDate()/isDateValueAllowed()
1839
1917
  // below. DATE values are sent as "YYYY-MM-DD"; DATETIME as one ISO-8601
1840
1918
  // value — never a date with a slot LABEL glued on ("...T13:00-14:00" is
@@ -1907,15 +1985,20 @@ await client.addToCart(cartId, {
1907
1985
  | GALLERY | `string[]` (URLs) | Multi-file upload |
1908
1986
  | DIMENSION/WEIGHT | `{ value, unit }` | Value + unit inputs |
1909
1987
 
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
- ```
1988
+ **Assigning fields to a product is dashboard-only — there is no SDK path to it.**
1989
+ `client.setProductCustomizationFields()` and `client.getProductCustomizationFields()`
1990
+ target `/api/v1/metafield-definitions/products/:productId/customization-fields`, which
1991
+ the public API does not expose; both return **404**. Those routes exist only on the
1992
+ dashboard API (`/api/stores/:storeId/metafield-definitions/products/:productId/customization-fields`),
1993
+ behind Clerk auth. Choose which customer-input definitions apply to a product in the
1994
+ dashboard.
1995
+
1996
+ > **Two different things share the name `getProductCustomizationFields`.** The
1997
+ > **exported helper** used above — `import { getProductCustomizationFields } from 'brainerce'`
1998
+ > — is a pure function that reads the definitions off a product you already fetched. It
1999
+ > works in every mode and is the one you want. The **client method** of the same name,
2000
+ > which writes the assignment, is the one that 404s. Reading is fully covered without it:
2001
+ > `product.customizationFields` is already on every product response.
1919
2002
 
1920
2003
  > **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
2004
 
@@ -2117,9 +2200,17 @@ const totals = getCartTotals(cart);
2117
2200
 
2118
2201
  ---
2119
2202
 
2120
- ### Guest Checkout (Submit Order)
2203
+ ### Guest Checkout (Submit Order) — no payment collected
2121
2204
 
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.
2205
+ > **⛔ Not for stores that take payment.** `submitGuestOrder()` posts the order
2206
+ > straight to `POST /orders`. It never creates a payment intent, so the order is
2207
+ > created **unpaid** and no card is ever charged. Use it only where checkout
2208
+ > collects no money — cash on delivery, manual invoicing, or a sandbox store.
2209
+ >
2210
+ > For every other store use `startGuestCheckout()`, which creates a real checkout
2211
+ > session from the session cart and hands you a `checkoutId` to run the payment
2212
+ > flow against. This is the same rule as the Critical Rule above; the two used to
2213
+ > disagree.
2123
2214
 
2124
2215
  ```typescript
2125
2216
  // Make sure cart has items, customer email, and shipping address
@@ -2227,7 +2318,8 @@ if (checkout.tracked) {
2227
2318
  const order = await client.completeGuestCheckout(checkout.checkoutId);
2228
2319
  console.log('Order created:', order.orderId);
2229
2320
  } else {
2230
- // Fallback to regular guest checkout
2321
+ // Fallback NO payment is collected on this path. Only reachable on a
2322
+ // cash-on-delivery / manual-invoice / sandbox store.
2231
2323
  const order = await client.submitGuestOrder();
2232
2324
  }
2233
2325
  ```
@@ -2426,8 +2518,11 @@ await client.getProduct('prod_tshirt', { regionId: 'region_eu' });
2426
2518
  await client.getProductBySlug('blue-shirt', { regionId: 'region_eu' });
2427
2519
  ```
2428
2520
 
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
2521
+ > **Display-only, and unlike checkout.** `regionId` on a _product read_ never
2522
+ > affects what is charged — it only changes what you render. `regionId` on
2523
+ > `createCheckout()` is different: it CAN charge the region currency (see
2524
+ > FX-at-checkout above). Do not carry the "display-only" assumption from here into
2525
+ > the checkout step. Works in
2431
2526
  > **all three modes** — vibe-coded (`vc_*`/`salesChannelId`), storefront (`storeId`),
2432
2527
  > and admin (`apiKey`). The response gains `displayPrice` whenever a daily FX rate
2433
2528
  > exists for the store/region currency pair in **either** direction (the overlay
@@ -2591,7 +2686,8 @@ const updatedCheckout = await client.setCheckoutCustomFields(checkoutId, {
2591
2686
  **DATE / DATETIME fields with availability constraints**
2592
2687
 
2593
2688
  A `DATE`/`DATETIME` field's `dateAvailability` (blocked weekdays, blocked specific
2594
- dates, min/max date range, and for `DATETIME` business hours + time
2689
+ dates, min/max date range, the relative bounds `leadTimeMinutes` / `cutoffTime` /
2690
+ `maxDaysAhead`, and — for `DATETIME` — business hours + time
2595
2691
  slots) is a merchant-configured restriction on which values the customer may
2596
2692
  pick. Use `computeAvailableSlots()` / `getBusinessHoursForDate()` /
2597
2693
  `isDateValueAllowed()` to drive your own date-picker/slot-picker UI — the SDK
@@ -2610,19 +2706,24 @@ const { timezone } = await client.getStoreInfo(); // IANA string, e.g. "Asia/Jer
2610
2706
  const deliveryField = fields.find((f) => f.key === 'delivery_slot');
2611
2707
  const availability = deliveryField?.dateAvailability;
2612
2708
 
2709
+ // The clock. Without it leadTimeMinutes/cutoffTime/maxDaysAhead are SKIPPED and
2710
+ // the picker offers days the server refuses; `now` defaults to this instant.
2711
+ const clock = { timezone };
2712
+
2613
2713
  // Disable days on your calendar of choice. Note the SECOND condition: once
2614
2714
  // businessHours has any entry it is an ALLOWLIST, so a weekday it doesn't
2615
2715
  // mention is closed all day even though the calendar rules accept it.
2616
2716
  const isDayDisabled = (ymd: string) =>
2617
- !isCalendarDateAllowed(ymd, availability) ||
2717
+ !isCalendarDateAllowed(ymd, availability, clock) ||
2618
2718
  (!!availability?.businessHours?.length &&
2619
- getBusinessHoursForDate(availability, ymd).length === 0);
2719
+ getBusinessHoursForDate(availability, ymd, clock).length === 0);
2620
2720
 
2621
2721
  // Once the customer picks a day, offer times. computeAvailableSlots() returns
2622
2722
  // [] when the field has no slotDurationMinutes — that is NOT "day closed",
2623
- // which is why the windows are checked separately.
2624
- const slots = computeAvailableSlots(availability, '2026-08-15'); // ["09:00", "09:30", ...]
2625
- const windows = getBusinessHoursForDate(availability, '2026-08-15'); // [{ weekday, open, close }]
2723
+ // which is why the windows are checked separately. A day with two windows
2724
+ // (mornings and evenings) yields both, in chronological order.
2725
+ const slots = computeAvailableSlots(availability, '2026-08-15', clock); // ["09:00", "09:30", ...]
2726
+ const windows = getBusinessHoursForDate(availability, '2026-08-15', clock); // [{ weekday, open, close }]
2626
2727
 
2627
2728
  if (slots.length) {
2628
2729
  // Render slot buttons; the submitted time must equal a slot start exactly.
@@ -2646,6 +2747,19 @@ What you read back is normalized, not the string you sent: `YYYY-MM-DD` for
2646
2747
  `DATE`, an ISO-8601 UTC instant for `DATETIME`. Use `parseDateFieldValue()` if
2647
2748
  you want to apply the exact same parse client-side before submitting.
2648
2749
 
2750
+ **Relative bounds.** `leadTimeMinutes` puts the earliest bookable moment at
2751
+ `now + leadTime`. `cutoffTime` ("HH:mm", store-local) pushes the earliest
2752
+ bookable DATE on by a further day once the store clock reaches it, which is how
2753
+ "order by 14:00 for tomorrow" is expressed. `maxDaysAhead` is a rolling ceiling
2754
+ counted from today. All three are re-resolved on every call, so unlike an
2755
+ absolute `minDate` they never go stale, and all three apply to plain `DATE`
2756
+ fields as well. They are accepted on **checkout** custom fields only: a product
2757
+ metafield and an order custom field are written by an admin rather than picked
2758
+ by a shopper, so there is no ordering moment to measure them from and the API
2759
+ rejects them there. `resolveRelativeBounds(availability, clock)` returns the
2760
+ concrete dates they currently mean, which is what to show a shopper who asked
2761
+ for something too soon.
2762
+
2649
2763
  The backend independently re-validates every submitted value against the same
2650
2764
  constraints at write time — this is a client-side UX aid, not the source of
2651
2765
  enforcement.
@@ -2917,16 +3031,21 @@ if (paypalProvider) {
2917
3031
  }
2918
3032
  ```
2919
3033
 
2920
- #### Get Payment Configuration (Single Provider)
3034
+ #### Get Payment Configuration (Single Provider) — DEPRECATED
2921
3035
 
2922
- If you only need the default provider, use this simpler method:
3036
+ > **`getPaymentConfig()` is `@deprecated`.** It only ever describes one provider, so
3037
+ > a store with an additive express method (PayPal, a wallet) renders wrong. Use
3038
+ > `getPaymentProviders()` above. This section documents the legacy shape for code
3039
+ > that still calls it.
2923
3040
 
2924
3041
  ```typescript
2925
3042
  const config = await client.getPaymentConfig();
2926
3043
 
2927
3044
  // Returns:
2928
3045
  // {
2929
- // provider: 'stripe' | 'paypal',
3046
+ // provider: string, // 'stripe' | 'paypal' | 'grow' | 'cardcom' | any
3047
+ // // installed marketplace payment app — NOT a
3048
+ // // closed union. Never switch on it exhaustively.
2930
3049
  // publicKey: 'pk_live_xxx...', // Stripe publishable key or PayPal client ID
2931
3050
  // supportedMethods: ['card', 'ideal', 'bancontact'],
2932
3051
  // testMode: false
@@ -2943,13 +3062,74 @@ const intent = await client.createPaymentIntent(checkout.id);
2943
3062
  // Returns:
2944
3063
  // {
2945
3064
  // id: 'pi_xxx...',
2946
- // clientSecret: 'pi_xxx_secret_xxx', // Used by Stripe.js/PayPal SDK
2947
- // amount: 9999, // In cents
3065
+ // clientSecret: 'pi_xxx_secret_xxx', // See the note below — NOT always a secret
3066
+ // amount: '99.99', // DECIMAL STRING, not cents. parseFloat() it.
2948
3067
  // currency: 'USD',
2949
- // status: 'requires_payment_method'
3068
+ // status: 'requires_payment_method',
3069
+ // provider: 'stripe', // which processor took the intent
3070
+ // clientSdk: { renderType: 'sdk-widget', /* … */ }, // HOW to render — see below
2950
3071
  // }
2951
3072
  ```
2952
3073
 
3074
+ > **`amount` is a decimal string in the charged currency (`"99.99"`), never an
3075
+ > integer of cents.** `parseFloat()` it before any math; do not divide by 100.
3076
+
3077
+ **`provider` and `clientSdk` are the two fields that decide what you render**, and
3078
+ both are omitted from most copy-paste snippets. `clientSdk.renderType` is one of
3079
+ `'sdk-widget' | 'iframe' | 'redirect' | 'sandbox' | 'embedded-fields'`:
3080
+
3081
+ | `renderType` | What `clientSecret` holds | What to do |
3082
+ | -------------- | ------------------------- | ------------------------------------------------------------------------------------------------ |
3083
+ | `'sandbox'` | (unused) | No payment UI — complete the checkout directly |
3084
+ | `'sdk-widget'` | Client secret / auth code | Load `clientSdk.scriptUrl`, init with `clientSdk.initConfig`, mount into `clientSdk.containerId` |
3085
+ | `'iframe'` | **A URL** | Load it in an iframe (inline if its path contains `/embed/`, else in a modal) |
3086
+ | `'redirect'` | **A URL** | Navigate the top-level window to it; on return call `confirmSdkPayment()` |
3087
+
3088
+ Branch on `clientSdk?.renderType`. **Never** branch on "does `clientSdk` exist" —
3089
+ every provider returns one, sandbox included — and never hard-code by provider name.
3090
+ Only `provider === 'stripe'` has a `clientSdk.initConfig.publishableKey`.
3091
+
3092
+ #### Confirm an SDK / redirect payment
3093
+
3094
+ ```typescript
3095
+ // confirmSdkPayment(checkoutId, providerResponseData?) => Promise<{ confirmed: boolean }>
3096
+ await client.confirmSdkPayment(checkoutId);
3097
+
3098
+ // With the provider's own callback payload (transactionId, transactionToken,
3099
+ // confirmation_number, …) when its SDK hands you one:
3100
+ await client.confirmSdkPayment(checkoutId, { transactionId: 'txn_123' });
3101
+ ```
3102
+
3103
+ Call it in two places:
3104
+
3105
+ - **In an in-page SDK's success callback** (`renderType: 'sdk-widget'`) — it tells
3106
+ the backend the payment succeeded, which triggers order creation.
3107
+ - **On the return page from a `renderType: 'redirect'` provider** — redirect
3108
+ providers don't capture until the server confirms, so this is what makes the
3109
+ backend verify with the provider and capture.
3110
+
3111
+ It is **idempotent** (safe if a webhook already captured) and safe to skip on
3112
+ failure — `getPaymentStatus()` / `waitForOrder()` re-verify server-side. Wrap it in
3113
+ `try/catch` and carry on:
3114
+
3115
+ ```typescript
3116
+ try {
3117
+ await client.confirmSdkPayment(checkoutId);
3118
+ } catch {
3119
+ // Not fatal — polling re-verifies.
3120
+ }
3121
+ const result = await client.waitForOrder(checkoutId);
3122
+ ```
3123
+
3124
+ Do **not** call it on your `cancelUrl` — the buyer abandoned; just let them retry.
3125
+
3126
+ `confirmGrowPayment()` is a deprecated wrapper around this method; call
3127
+ `confirmSdkPayment()` directly.
3128
+
3129
+ > `confirmSdkPayment()`, `getPaymentStatus()`, `createPaymentIntent()`,
3130
+ > `getPaymentProviders()` and `waitForOrder()` are **sales-channel mode only** —
3131
+ > they throw `BrainerceError` 400 on a `storeId` or `apiKey` client.
3132
+
2953
3133
  **Routing to a specific provider (`providerId`).** With `getPaymentProviders()` you
2954
3134
  render additive **express buttons** (e.g. PayPal as a `WALLET`) alongside the primary
2955
3135
  card form. When the buyer taps one, pass that provider's `id` so the charge routes to
@@ -2974,7 +3154,10 @@ selectable.
2974
3154
  Use the client secret with Stripe.js to collect payment:
2975
3155
 
2976
3156
  ```typescript
2977
- // Initialize Stripe.js with the public key from getPaymentConfig()
3157
+ // Initialize Stripe.js with the publishable key. Prefer
3158
+ // intent.clientSdk.initConfig.publishableKey (per-intent, correct for the
3159
+ // provider that actually took the charge); config.publicKey is the deprecated
3160
+ // getPaymentConfig() fallback.
2978
3161
  const stripe = await loadStripe(config.publicKey);
2979
3162
 
2980
3163
  // Create Elements and Payment Element
@@ -3131,7 +3314,10 @@ export default function CheckoutPaymentPage() {
3131
3314
  const checkoutId = new URLSearchParams(window.location.search).get('checkout_id');
3132
3315
  if (!checkoutId) throw new Error('No checkout ID');
3133
3316
 
3134
- // Get payment configuration
3317
+ // Get payment configuration.
3318
+ // NOTE: getPaymentConfig() is @deprecated — single-provider only. In new
3319
+ // code use getPaymentProviders() and render additive express methods
3320
+ // alongside the primary card form. Kept here as a minimal Stripe example.
3135
3321
  const config = await client.getPaymentConfig();
3136
3322
  setPaymentConfig(config);
3137
3323
 
@@ -3306,12 +3492,37 @@ if (intent.clientSdk?.renderType === 'sandbox') {
3306
3492
 
3307
3493
  #### Register Customer
3308
3494
 
3495
+ > **Password policy — enforced on `registerCustomer()` AND `resetPassword()`.**
3496
+ > At least **8 characters**, with at least one **lowercase** letter, one
3497
+ > **uppercase** letter, one **digit**, and one **special character**
3498
+ > (`/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^a-zA-Z0-9]).{8,}$/`). A password that
3499
+ > fails returns HTTP 400 with the message _"Password must contain at least 1
3500
+ > uppercase letter, 1 lowercase letter, 1 number, and 1 special character"_.
3501
+ >
3502
+ > `securepassword123` fails (no uppercase, no special). `Password123` fails (no
3503
+ > special). `SecurePass123!` passes. Mirror the full rule in your own client-side
3504
+ > validation and in the field's helper text — a form that only says "min 8
3505
+ > characters" produces a 400 the shopper cannot explain, and render the server's
3506
+ > message verbatim when one comes back.
3507
+
3508
+ > **Birthday fields.** `birthMonth` (1-12) and `birthDay` (1-31) are optional,
3509
+ > month and day only, never a year. Send both or neither: one on its own is
3510
+ > rejected with HTTP 400, and so is a day the month does not have. When
3511
+ > `getStoreInfo().requireBirthday` is true the merchant made the birthday
3512
+ > mandatory on that sales channel and a register call without it fails with
3513
+ > HTTP 400. That flag only reaches sales-channel mode (`salesChannelId`); a
3514
+ > `storeId`-mode storefront never receives it and its register route never
3515
+ > enforces it.
3516
+
3309
3517
  ```typescript
3310
3518
  const auth = await client.registerCustomer({
3311
3519
  email: 'customer@example.com',
3312
- password: 'securepassword123',
3520
+ password: 'SecurePass123!',
3313
3521
  firstName: 'John',
3314
3522
  lastName: 'Doe',
3523
+ // Optional, unless getStoreInfo().requireBirthday is true. Both or neither.
3524
+ birthMonth: 4,
3525
+ birthDay: 17,
3315
3526
  });
3316
3527
 
3317
3528
  // Check if email verification is required
@@ -3319,7 +3530,8 @@ if (auth.requiresVerification) {
3319
3530
  localStorage.setItem('verificationToken', auth.token);
3320
3531
  window.location.href = '/verify-email';
3321
3532
  } else {
3322
- setCustomerToken(auth.token);
3533
+ client.setCustomerToken(auth.token);
3534
+ await client.syncCartOnLogin(); // REQUIRED — claims the guest cart onto the account
3323
3535
  // Redirect back to store, not /account
3324
3536
  window.location.href = '/';
3325
3537
  }
@@ -3328,8 +3540,9 @@ if (auth.requiresVerification) {
3328
3540
  #### Login Customer
3329
3541
 
3330
3542
  ```typescript
3331
- const auth = await client.loginCustomer('customer@example.com', 'password123');
3332
- setCustomerToken(auth.token);
3543
+ const auth = await client.loginCustomer('customer@example.com', 'SecurePass123!');
3544
+ client.setCustomerToken(auth.token);
3545
+ await client.syncCartOnLogin(); // REQUIRED — claims the guest cart onto the account
3333
3546
 
3334
3547
  // Best practice: redirect back to previous page or home
3335
3548
  const returnUrl = localStorage.getItem('returnUrl') || '/';
@@ -3337,6 +3550,13 @@ localStorage.removeItem('returnUrl');
3337
3550
  window.location.href = returnUrl;
3338
3551
  ```
3339
3552
 
3553
+ > **`setCustomerToken()` is a plain field setter — it does not touch the cart.**
3554
+ > Always follow it with `await client.syncCartOnLogin()`. Skip it and the
3555
+ > shopper's guest cart is never attached to their account, which quietly breaks
3556
+ > every identity-keyed feature: first-order discounts, per-customer coupon caps,
3557
+ > and abandoned-cart recovery. Same rule after `verifyEmail()` and after
3558
+ > `exchangeOAuthCode()`.
3559
+
3340
3560
  > **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
3561
 
3342
3562
  #### Forgot Password
@@ -3352,7 +3572,7 @@ await client.forgotPassword('customer@example.com');
3352
3572
  ```typescript
3353
3573
  // On /reset-password page, extract token from URL
3354
3574
  const token = new URLSearchParams(window.location.search).get('token');
3355
- const result = await client.resetPassword(token!, 'newSecurePassword123');
3575
+ const result = await client.resetPassword(token!, 'NewSecurePass123!');
3356
3576
  // result.message = "Password has been reset successfully"
3357
3577
  ```
3358
3578
 
@@ -3373,6 +3593,12 @@ console.log(profile.firstName);
3373
3593
  console.log(profile.email);
3374
3594
  console.log(profile.addresses);
3375
3595
 
3596
+ // Birthday the customer saved: month and day only, never a year. Both fields
3597
+ // arrive together or neither does, so testing one is enough.
3598
+ if (profile.birthMonth && profile.birthDay) {
3599
+ console.log(`Birthday: ${profile.birthDay}/${profile.birthMonth}`);
3600
+ }
3601
+
3376
3602
  // profile.role is a free-form segment the merchant sets from the dashboard
3377
3603
  // (e.g. "wholesale", "vip") — not customer-editable. Use it to gate custom
3378
3604
  // storefront features/UI: wholesale pricing, a VIP section, etc.
@@ -3381,6 +3607,30 @@ if (profile.role === 'wholesale') {
3381
3607
  }
3382
3608
  ```
3383
3609
 
3610
+ #### Update Customer Profile
3611
+
3612
+ Storefront or vibe-coded mode, requires `customerToken`. The call returns the
3613
+ saved `CustomerProfile`, so re-render the form from the response instead of
3614
+ from what you sent. `email` and `role` are not customer-editable and are not
3615
+ accepted here.
3616
+
3617
+ ```typescript
3618
+ const updated = await client.updateMyProfile({
3619
+ firstName: 'John',
3620
+ lastName: 'Doe',
3621
+ phone: '+15550100',
3622
+ acceptsMarketing: true,
3623
+ // Birthday: month and day only, never a year. Send both or neither, and the
3624
+ // day has to exist in the month (day 31 in February is rejected with 400).
3625
+ birthMonth: 4,
3626
+ birthDay: 17,
3627
+ });
3628
+
3629
+ // The saved birthday comes back on the response and on getMyProfile(), so the
3630
+ // profile form shows what the customer stored instead of two empty fields.
3631
+ console.log(updated.birthMonth, updated.birthDay);
3632
+ ```
3633
+
3384
3634
  #### Get Customer Orders
3385
3635
 
3386
3636
  ```typescript
@@ -3462,10 +3712,15 @@ await client.registerCustomer({ email, password, referralCode: refFromQuery });
3462
3712
  // bonus (held through the program's pending window, like order points).
3463
3713
  ```
3464
3714
 
3465
- Birthday gifts need no SDK calls beyond profile data: set the customer's
3466
- `birthMonth`/`birthDay` (1-12 / 1-31, no year) via `updateMyProfile()` and the
3467
- platform emails a one-time gift coupon ahead of their birthday automatically
3468
- (when the store has it enabled).
3715
+ Birthday gifts need no SDK calls beyond profile data: save the customer's
3716
+ `birthMonth`/`birthDay` (1-12 / 1-31, month and day only, never a year) via
3717
+ `updateMyProfile()` and the platform emails a one-time gift coupon ahead of
3718
+ their birthday automatically (when the store has it enabled). Both values come
3719
+ back on `getMyProfile()`, on `getCheckoutPrefillData()` and on the `Customer`
3720
+ read types, so a profile form renders the birthday the customer already gave
3721
+ you rather than an empty pair of fields. `registerCustomer()` accepts the same
3722
+ two fields, and a channel with `requireBirthday` turned on insists on them at
3723
+ signup.
3469
3724
 
3470
3725
  #### Paid Loyalty Membership
3471
3726
 
@@ -3555,8 +3810,10 @@ When `requiresVerification` is true in the registration response, the customer n
3555
3810
  ```typescript
3556
3811
  const auth = await client.registerCustomer({
3557
3812
  email: 'customer@example.com',
3558
- password: 'securepassword123',
3813
+ password: 'SecurePass123!',
3559
3814
  firstName: 'John',
3815
+ // Add birthMonth + birthDay here too when getStoreInfo().requireBirthday is
3816
+ // true, or this call fails with HTTP 400 before any email is sent.
3560
3817
  });
3561
3818
 
3562
3819
  if (auth.requiresVerification) {
@@ -4495,11 +4752,9 @@ await client.createAttributeOption(sizeAttr.id, {
4495
4752
  source: 'GLOBAL',
4496
4753
  });
4497
4754
 
4498
- // Get options, update, delete
4755
+ // Get options, update the attribute, delete the attribute
4499
4756
  const options = await client.getAttributeOptions(colorAttr.id);
4500
4757
  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
4758
  await client.deleteAttribute(colorAttr.id);
4504
4759
 
4505
4760
  // Storefront: render swatches from product data
@@ -4510,6 +4765,15 @@ const swatches = getProductSwatches(product);
4510
4765
  // Returns: [{ attributeName: 'Color', displayType: 'COLOR_SWATCH', options: [{ name: 'Red', swatchColor: '#FF0000', ... }] }]
4511
4766
  ```
4512
4767
 
4768
+ > **Editing or deleting a single option is dashboard-only.** `/api/v1/attributes/:id/options`
4769
+ > exposes exactly two verbs — `GET` (list) and `POST` (add). There is no per-option route
4770
+ > on the public API, so `client.updateAttributeOption()` and `client.deleteAttributeOption()`
4771
+ > return **404**. The per-option routes exist only on the dashboard API
4772
+ > (`PUT` / `DELETE /api/stores/:storeId/attributes/:id/options/:optionId`), behind Clerk
4773
+ > auth. Recolour a swatch or drop an option in the dashboard. Everything else on
4774
+ > attributes — create, list, `updateAttribute`, `deleteAttribute`, `getAttributeOptions`,
4775
+ > `createAttributeOption` — works from the SDK as shown above.
4776
+
4513
4777
  ### Shipping Configuration
4514
4778
 
4515
4779
  ```typescript
@@ -4845,8 +5109,9 @@ vibe-coded site **only if** it has been explicitly published to that
4845
5109
  connection. Entities with no publish rows are invisible to every vibe-coded
4846
5110
  site, including in product responses (the related categories/brands/tags
4847
5111
  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.
5112
+ Merchants publish through the dashboard's per-row Platforms cell; for
5113
+ categories, tags and brands the admin SDK below does the same thing. Custom
5114
+ fields are the exception — see the note under the snippet.
4850
5115
 
4851
5116
  ```typescript
4852
5117
  // Publish a product to a sales channel (accepts record ID or vc_* connection ID)
@@ -4856,24 +5121,38 @@ await client.unpublishProductFromSalesChannel('prod_id', 'vc_conn_id');
4856
5121
 
4857
5122
  // Publish a category to a specific vibe-coded site
4858
5123
  await client.publishCategoryToVibeCodedSite('cat_id', 'conn_id');
4859
- // Tag, brand, metafield definition follow the same shape:
5124
+ // Tag and brand follow the same shape:
4860
5125
  await client.publishTagToVibeCodedSite('tag_id', 'conn_id');
4861
5126
  await client.publishBrandToVibeCodedSite('brand_id', 'conn_id');
4862
- await client.publishMetafieldDefinitionToVibeCodedSite('def_id', 'conn_id');
4863
5127
 
4864
5128
  // Unpublish — entity is no longer visible to that site (but stays visible
4865
5129
  // to every other site if no other publishes exist).
4866
5130
  await client.unpublishCategoryFromVibeCodedSite('cat_id', 'conn_id');
4867
5131
  await client.unpublishTagFromVibeCodedSite('tag_id', 'conn_id');
4868
5132
  await client.unpublishBrandFromVibeCodedSite('brand_id', 'conn_id');
4869
- await client.unpublishMetafieldDefinitionFromVibeCodedSite('def_id', 'conn_id');
4870
5133
 
4871
5134
  // 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:
5135
+ // `channelPublishes` is included on every list/get for these 4 entity types:
4873
5136
  const cat = await client.getCategory('cat_id');
4874
- cat.vibeCodedPublishes; // [{ connection: { id, name, connectionId } }, ...]
5137
+ cat.channelPublishes; // [{ salesChannel: { id, name, connectionId } }, ...]
4875
5138
  ```
4876
5139
 
5140
+ > **Custom fields are the exception — publish them in the dashboard.**
5141
+ > `client.publishMetafieldDefinitionToVibeCodedSite()` and
5142
+ > `client.unpublishMetafieldDefinitionFromVibeCodedSite()` target
5143
+ > `/api/v1/metafield-definitions/:id/publish-vibe-coded`, which the public API does not
5144
+ > expose; both return **404**. Only the dashboard API carries those routes
5145
+ > (`POST /api/stores/:storeId/metafield-definitions/:id/publish` and `…/unpublish`),
5146
+ > behind Clerk auth. **Reading is unaffected** — `getMetafieldDefinitions()` and
5147
+ > `getMetafieldDefinition()` both return `channelPublishes` exactly like the other three
5148
+ > entity types, so you can still see which sites a custom field is published to; you just
5149
+ > cannot change it from the SDK.
5150
+
5151
+ > **`vibeCodedPublishes` and its `connection` sub-key are deprecated.** Both are
5152
+ > still emitted as back-compat aliases of `channelPublishes` / `salesChannel` and
5153
+ > are removed in SDK 2.0. Read `channelPublishes[].salesChannel` in new code —
5154
+ > the customer section below already does.
5155
+
4877
5156
  **Cross-account isolation:** publishing only succeeds when the entity and the
4878
5157
  target vibe-coded connection both belong to the same account. Cross-account
4879
5158
  calls fail with `404 Not Found` (the connection ID is treated as if it doesn't
@@ -4919,48 +5198,38 @@ not prevent that person from buying on that storefront, and the row comes back
4919
5198
  the next time they sign in or order there. There is no API to bar a customer
4920
5199
  from a sales channel.
4921
5200
 
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.
5201
+ ### Store Team Management — dashboard-only
5202
+
5203
+ Each store has its own team with roles (`OWNER`, `MANAGER`, `STAFF`, `VIEWER`) and
5204
+ granular permissions, including per-sales-channel scoping. **Managing it is a dashboard
5205
+ operation. There is no SDK path to it, and this is deliberate.**
5206
+
5207
+ This section previously showed nine store-level team calls. They do not work:
5208
+ `getStoreTeam`, `inviteStoreMember`, `updateStoreMember`, `updateStoreMemberSalesChannels`,
5209
+ `removeStoreMember`, `resendStoreInvitation` and `revokeStoreInvitation` call
5210
+ `/api/v1/stores/:storeId/team…`, and `getMyStores` / `getMyStorePermissions` call
5211
+ `/api/v1/me/stores…`. The public API exposes neither prefix, so every one of them returns
5212
+ **404**.
5213
+
5214
+ **This is not a path typo waiting on a fix.** The real endpoints exist at
5215
+ `/api/stores/:storeId/team…`, guarded by `DashboardOnlyGuard`, which rejects API-key
5216
+ principals _by design_: an API key carries a `storeId` but never a `userId`, and the
5217
+ team service resolves a missing `userId` to an OWNER role. Failing closed at the boundary
5218
+ is what stops an API key from escalating its own team permissions. Pointing the SDK at
5219
+ the correct path would earn a `403` instead of a `404`. Invite, re-scope and remove
5220
+ members in the dashboard.
5221
+
5222
+ > **The older account-level methods are not a substitute for this.** `getTeamMembers`,
5223
+ > `getTeamInvitations`, `inviteTeamMember`, `resendTeamInvitation`, `revokeTeamInvitation`,
5224
+ > `updateTeamMemberRole` and `removeTeamMember` do still reach `/api/v1/team/…` — but they
5225
+ > manage the **account** team, not a store's. They will not invite anyone to a store or
5226
+ > scope a member to a sales channel; only the dashboard does that.
5227
+ >
5228
+ > **For the account team, they remain the supported call.** All seven are tagged
5229
+ > `@deprecated`, which records an intent to retire them — not a migration you can perform
5230
+ > today. There is no API-key replacement: the store-level methods named above are
5231
+ > dashboard-only. Keep using these until an API-key route ships, and expect the tag to
5232
+ > outlive this note.
4964
5233
 
4965
5234
  ### Email Settings & Templates
4966
5235
 
@@ -5030,10 +5299,6 @@ const { summarized, summary } = await client.summarizeBotConversation(conversati
5030
5299
  ### Sync Conflict Resolution
5031
5300
 
5032
5301
  ```typescript
5033
- // Product sync conflicts
5034
- const conflicts = await client.getSyncConflicts();
5035
- await client.resolveSyncConflict('conflict_id', 'MERGE'); // or 'CREATE_NEW'
5036
-
5037
5302
  // Metafield conflicts
5038
5303
  const metafieldConflicts = await client.getMetafieldConflicts();
5039
5304
  await client.resolveMetafieldConflict('conflict_id', {
@@ -5042,6 +5307,12 @@ await client.resolveMetafieldConflict('conflict_id', {
5042
5307
  await client.ignoreMetafieldConflict('conflict_id');
5043
5308
  ```
5044
5309
 
5310
+ > **Product sync conflicts are dashboard-only.** `client.getSyncConflicts()` and
5311
+ > `client.resolveSyncConflict()` call `/api/v1/sync-conflicts`, which the public API does
5312
+ > not expose; both return **404**. Review a product conflict and pick `MERGE` or
5313
+ > `CREATE_NEW` in the dashboard. The three metafield-conflict methods above are a separate
5314
+ > surface (`/api/v1/metafield-conflicts`) and do work with an API key.
5315
+
5045
5316
  ### OAuth Provider Configuration
5046
5317
 
5047
5318
  ```typescript
@@ -5059,7 +5330,7 @@ await client.deleteOAuthProvider('GOOGLE');
5059
5330
 
5060
5331
  ### Modifier Groups Management
5061
5332
 
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`.
5333
+ 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
5334
 
5064
5335
  ```typescript
5065
5336
  // Group CRUD
@@ -5889,6 +6160,8 @@ export default function RegisterPage() {
5889
6160
  setLoading(true);
5890
6161
  setError('');
5891
6162
  try {
6163
+ // Add birthMonth + birthDay to this call (both, never a year) when
6164
+ // getStoreInfo().requireBirthday is true for the channel.
5892
6165
  const auth = await client.registerCustomer({ email, password, firstName, lastName });
5893
6166
 
5894
6167
  // Check if email verification is required
@@ -5920,7 +6193,9 @@ export default function RegisterPage() {
5920
6193
  <input placeholder="Last Name" value={lastName} onChange={e => setLastName(e.target.value)} required className="border p-2 rounded" />
5921
6194
  </div>
5922
6195
  <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" />
6196
+ {/* The API rejects anything weaker: 8+ chars with a lowercase, an uppercase,
6197
+ a digit AND a special character. `minLength={8}` alone lets a 400 through. */}
6198
+ <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
6199
  <button type="submit" disabled={loading} className="w-full bg-black text-white py-3 rounded">
5925
6200
  {loading ? 'Creating Account...' : 'Create Account'}
5926
6201
  </button>
@@ -5988,6 +6263,9 @@ export default function AccountPage() {
5988
6263
  <h2 className="text-xl font-bold mb-4">Profile</h2>
5989
6264
  <p><strong>Name:</strong> {profile.firstName} {profile.lastName}</p>
5990
6265
  <p><strong>Email:</strong> {profile.email}</p>
6266
+ {profile.birthMonth && profile.birthDay && (
6267
+ <p><strong>Birthday:</strong> {profile.birthDay}/{profile.birthMonth}</p>
6268
+ )}
5991
6269
  </div>
5992
6270
 
5993
6271
  <div className="border rounded p-6">
@@ -6109,6 +6387,89 @@ const forms = await brainerce.contactForms.list();
6109
6387
 
6110
6388
  **Rate limit:** 3 submissions per 60 seconds per IP. Include a hidden honeypot field (and do not submit it) — bots that auto-fill every input will be rejected.
6111
6389
 
6390
+ **A form keyed `newsletter` is still an inquiry.** The key is a label, not a behaviour: the submission files a message and never touches marketing consent, so that address can never receive a campaign. For a mailing list, use [Newsletter Signup](#newsletter-signup-marketing-opt-in).
6391
+
6392
+ ---
6393
+
6394
+ ## Newsletter Signup (marketing opt-in)
6395
+
6396
+ **SDK >= 1.60.** The email-capture popup, the footer subscribe bar, the exit-intent modal.
6397
+
6398
+ ```typescript
6399
+ await brainerce.marketing.subscribe({
6400
+ email: 'jane@example.com',
6401
+ locale: 'he', // language of the confirmation email
6402
+ source: 'popup', // free-form, for the merchant's reporting
6403
+ honeypot: hiddenFieldValue, // must be empty
6404
+ });
6405
+ // → { ok: true }
6406
+ ```
6407
+
6408
+ Also accepts `firstName`, `lastName`, and `sourceMetadata` (referrer, UTM params, the page the popup fired on).
6409
+
6410
+ **⛔ It does not subscribe anyone.** The contact is created and mailed a confirmation link; the address is unmailable — and invisible to every campaign audience — until the recipient clicks it. Render **"Check your email to confirm — including your spam folder"** on success, never "You're subscribed". The spam-folder half matters: a confirmation filtered there is the commonest reason a signup never converts, and the 24-hour resend cooldown means no second copy arrives. Single opt-in is not available: without the click, anyone could subscribe anyone else's address.
6411
+
6412
+ **⛔ The response carries no information.** `{ ok: true }` is returned identically for a brand-new address, one that confirmed months ago, one inside its 24-hour resend cooldown, and one suppressed after a hard bounce — otherwise the form would become a way to test who shops at this store. Show one message for every success; there is no branch to write.
6413
+
6414
+ **Rate limit:** 3 requests per 60 seconds per IP, plus one confirmation email per address per store per 24 hours. A submission inside that cooldown still returns `{ ok: true }` and silently sends nothing — do not treat it as a failure or retry it.
6415
+
6416
+ **Locale:** pass it on a multi-language storefront, or the confirmation email falls back to the store's language. `he` and `en` are written; anything else gets English.
6417
+
6418
+ **No discount code is minted.** For a "10% off your first order" popup, the merchant creates one coupon with the `customer_first_order` condition and you display that fixed code after a successful call.
6419
+
6420
+ The contact appears at `Customers` in the dashboard immediately, with **Accepts marketing** off; it flips on at confirmation. It is an ordinary guest customer record — no password, no account — and is the same row if that person later registers or checks out.
6421
+
6422
+ ---
6423
+
6424
+ ## Back-in-Stock Alerts
6425
+
6426
+ **SDK >= 1.61.** The "email me when this is back" button on a sold-out product.
6427
+
6428
+ ```typescript
6429
+ await brainerce.stockAlerts.subscribe({
6430
+ email: 'jane@example.com',
6431
+ productId: product.id,
6432
+ variantId: selectedVariant.id, // pass on ANY product with variants
6433
+ locale: 'he', // language of the alert email
6434
+ honeypot: hiddenFieldValue, // must be empty
6435
+ });
6436
+ // → { ok: true }
6437
+ ```
6438
+
6439
+ **⛔ It is not a subscription.** One email, about one item, carrying a link that stops it. No customer account is created and no marketing consent is granted. Label the button **"Email me when it's back"**, never "Subscribe" — and because it grants no consent, never hide it from a shopper who unsubscribed from your marketing.
6440
+
6441
+ **⛔ Render it only when `getStoreInfo().stockAlertsEnabled !== false`, the item is out of stock, AND it cannot be backordered.** Requests for anything else — a storefront whose merchant switched the feature off, an in-stock item, a backorderable one, an untracked one, an unknown product id — are silently ignored, so a button in the wrong place looks like it worked and does nothing.
6442
+
6443
+ ```typescript
6444
+ const store = await brainerce.getStoreInfo();
6445
+ // `inv` is the SELECTED VARIANT's inventory when there is one, else the product's.
6446
+ const inv = selectedVariant?.inventory ?? product.inventory;
6447
+
6448
+ const canOfferStockAlert =
6449
+ store.stockAlertsEnabled !== false &&
6450
+ inv?.trackingMode === 'TRACKED' &&
6451
+ !inv.canPurchase &&
6452
+ (inv.backorderMode ?? 'NONE') === 'NONE';
6453
+ ```
6454
+
6455
+ `backorderMode` is on `InventoryInfo` from SDK 1.61; older backends omit it, so treat `undefined` as `'NONE'`.
6456
+
6457
+ The merchant controls the switch — and how many people are emailed per unit restocked — under **Channel settings → Inventory**, alongside the low-stock warning.
6458
+
6459
+ **⛔ Pass `variantId` on every variable product.** Without it the alert waits on the product as a whole, so a shopper who wanted the medium is mailed when the small returns and arrives to find their size still gone.
6460
+
6461
+ **⛔ The response carries no information.** `{ ok: true }` is returned identically for a new request, a duplicate, an unknown product, an item already in stock, and an address suppressed after a hard bounce — otherwise the button would become a way to read the store's stock levels. Show one message for every success; there is no branch to write.
6462
+
6463
+ **Sending is not immediate, and not to everyone.** Availability is `total - reserved`, so an expiring cart briefly lifts a sold-out item above zero; the alert waits for stock to hold for a few minutes, then goes out in waves sized to the units that came back (500 waiting and 3 units restocked is roughly 9 emails, oldest request first). A shopper can therefore sit through a restock without hearing, so never promise "you'll be the first to know".
6464
+
6465
+ **Rate limit:** 5 requests per 60 seconds per IP, at most 25 open alerts per address per store, and a 90-day life on an unfired alert. A duplicate request is a no-op, not a second alert; once an alert has fired the person can ask again the next time that item sells out.
6466
+
6467
+ **Locale:** pass it on a multi-language storefront, or the alert falls back to the store's language. `he` and `en` are written; anything else gets English.
6468
+
6469
+ **What it does not do:** no SMS or WhatsApp, no price-drop alerts, and no merchant-editable template — the body is fixed so it can never start carrying a discount code, which would turn a transactional message into a marketing one needing an unsubscribe link it does not have.
6470
+
6471
+ The merchant reads the demand at `Products → Back-in-Stock Waitlist`: products ranked by how many people are waiting, with the addresses behind each number. There is no way to mail those people anything else from there, by design.
6472
+
6112
6473
  ---
6113
6474
 
6114
6475
  ## Storefront Bot (AI chat widget)
@@ -6188,16 +6549,62 @@ export async function POST(req: Request) {
6188
6549
 
6189
6550
  ### Webhook Events
6190
6551
 
6191
- | Event | Description |
6192
- | -------------------- | ------------------------------- |
6193
- | `product.created` | New product created |
6194
- | `product.updated` | Product details changed |
6195
- | `product.deleted` | Product removed |
6196
- | `inventory.updated` | Stock levels changed |
6197
- | `order.created` | New order received |
6198
- | `order.updated` | Order status changed |
6199
- | `cart.abandoned` | Cart abandoned (no activity) |
6200
- | `checkout.completed` | Checkout completed successfully |
6552
+ **These 21 event types are what a subscription can actually register.** The
6553
+ backend validates the `events` array on create against exactly this list, so
6554
+ anything outside it is rejected rather than silently accepted.
6555
+
6556
+ | Event | Description |
6557
+ | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
6558
+ | `order.created` | New order placed (any payment status) |
6559
+ | `order.updated` | Order metadata changed (status, address, items) |
6560
+ | `order.paid` | Order is paid — provider capture **or** a merchant-recorded out-of-band payment (cash on delivery, bank transfer). Never assume a provider was involved; `payment.succeeded` does **not** fire for these |
6561
+ | `order.fulfilled` | All items marked shipped/delivered |
6562
+ | `order.cancelled` | Order cancelled (by merchant or customer) |
6563
+ | `order.refunded` | Order fully or partially refunded |
6564
+ | `customer.created` | New customer account created |
6565
+ | `customer.updated` | Customer profile or contact details changed |
6566
+ | `customer.deleted` | Customer account deleted |
6567
+ | `product.created` | New product added to catalog |
6568
+ | `product.updated` | Product attributes, variants, or pricing changed |
6569
+ | `product.deleted` | Product removed from catalog |
6570
+ | `inventory.updated` | Stock level changed (any reason) |
6571
+ | `inventory.low` | Stock fell below the low-stock threshold |
6572
+ | `checkout.completed` | Checkout completed (synonym of `order.created` for now) |
6573
+ | `checkout.abandoned` | Cart inactive for 1+ hours with no completion |
6574
+ | `payment.succeeded` | Payment provider confirmed funds captured |
6575
+ | `payment.failed` | Payment provider rejected the transaction |
6576
+ | `payment.refunded` | Refund posted to the customer |
6577
+ | `blog.post.published` | Post went live (manual, scheduled, or SEO Autopilot) |
6578
+ | `blog.post.updated` | Published post content changed |
6579
+
6580
+ Payload shapes for each are in the
6581
+ [Event Catalogue](https://brainerce.com/docs/webhooks/events).
6582
+
6583
+ `customer.created` now fires for **shopper self-signup on your storefront**, not
6584
+ just merchant-created customers, so a storefront that registers customers will
6585
+ start seeing it.
6586
+
6587
+ > **⚠️ The `WebhookEventType` type does not match this table yet — in both
6588
+ > directions.** Treat the table, not the type, as the truth about what you can
6589
+ > subscribe to.
6590
+ >
6591
+ > **14 subscribable events are missing from the type:** `order.paid`,
6592
+ > `order.fulfilled`, `order.cancelled`, `order.refunded`, `customer.created`,
6593
+ > `customer.updated`, `customer.deleted`, `inventory.low`, `checkout.abandoned`,
6594
+ > `payment.succeeded`, `payment.failed`, `payment.refunded`,
6595
+ > `blog.post.published`, `blog.post.updated`. So
6596
+ > `isWebhookEventType(event, 'customer.created')` and a
6597
+ > `createWebhookHandler({ 'order.paid': … })` key **fail to compile**, even
6598
+ > though both deliver correctly at runtime. Cast the name
6599
+ > (`'customer.created' as WebhookEventType`) or read `event.event` as a
6600
+ > `string` and switch on it yourself. Do not conclude the event does not exist.
6601
+ >
6602
+ > **8 names in the type cannot be subscribed to at all:** `coupon.created`,
6603
+ > `coupon.updated`, `coupon.deleted`, `cart.created`, `cart.updated`,
6604
+ > `cart.abandoned`, `checkout.started`, `checkout.failed`. These compile
6605
+ > cleanly and then fail at subscription time. `cart.abandoned` in particular
6606
+ > was listed as a supported event here for a long time — use
6607
+ > `checkout.abandoned` instead.
6201
6608
 
6202
6609
  ---
6203
6610
 
@@ -6344,7 +6751,8 @@ const handleAddToCart = async (productId: string, quantity: number) => {
6344
6751
  }
6345
6752
  };
6346
6753
 
6347
- // Checkout with toast feedback
6754
+ // Checkout with toast feedback (cash-on-delivery / sandbox store — a store that
6755
+ // takes payment must run the checkout + payment-intent flow instead)
6348
6756
  const handleCheckout = async () => {
6349
6757
  try {
6350
6758
  const order = await client.submitGuestOrder();
@@ -6420,6 +6828,7 @@ export function useBrainerceAction<T>() {
6420
6828
  const { execute, isLoading } = useBrainerceAction();
6421
6829
 
6422
6830
  const handlePlaceOrder = () => {
6831
+ // Cash-on-delivery / sandbox only — submitGuestOrder() collects no payment.
6423
6832
  execute(() => client.submitGuestOrder(), {
6424
6833
  successMessage: 'Order placed successfully!',
6425
6834
  onSuccess: (order) => navigate(`/order/${order.orderId}`),