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