brainerce 2.0.0 → 2.1.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 +757 -295
- package/dist/index.d.mts +956 -186
- package/dist/index.d.ts +956 -186
- package/dist/index.js +454 -130
- package/dist/index.mjs +454 -130
- package/package.json +84 -84
package/README.md
CHANGED
|
@@ -5,17 +5,17 @@ Official SDK for building e-commerce storefronts with **Brainerce Platform**.
|
|
|
5
5
|
This SDK provides a complete solution for vibe-coded sites, AI-built stores (Cursor, Lovable, v0), and custom storefronts to connect to Brainerce's unified commerce API.
|
|
6
6
|
|
|
7
7
|
> **AI Agents / Vibe Coders (Cursor, Lovable, Claude Code, VS Code):** Use the MCP server for AI-powered store building: `npx @brainerce/mcp-server`. It provides docs, code templates, and live store capabilities directly inside your IDE.
|
|
8
|
-
> Note: the MCP server runs inside your IDE
|
|
8
|
+
> Note: the MCP server runs inside your IDE. It is not available in chat-only tools like Google AI Studio or ChatGPT.
|
|
9
9
|
|
|
10
|
-
## Three SDK modes
|
|
10
|
+
## Three SDK modes: choose the right one
|
|
11
11
|
|
|
12
12
|
The exported class is **`BrainerceClient`**. Which mode you get is decided by which key you pass to its constructor:
|
|
13
13
|
|
|
14
|
-
| Mode | Config key | Use for | Where to run
|
|
15
|
-
| ----------------- | ------------------------ | ---------------------------------------- |
|
|
16
|
-
| **Sales channel** | `salesChannelId: 'vc_*'` | Building the customer-facing store | Browser / client-side
|
|
17
|
-
| **Storefront** | `storeId` | A public storefront on a published store | Browser / client-side
|
|
18
|
-
| **Admin** | `apiKey: 'brainerce_*'` | Managing products, team, settings | Server only
|
|
14
|
+
| Mode | Config key | Use for | Where to run |
|
|
15
|
+
| ----------------- | ------------------------ | ---------------------------------------- | ---------------------------------- |
|
|
16
|
+
| **Sales channel** | `salesChannelId: 'vc_*'` | Building the customer-facing store | Browser / client-side |
|
|
17
|
+
| **Storefront** | `storeId` | A public storefront on a published store | Browser / client-side |
|
|
18
|
+
| **Admin** | `apiKey: 'brainerce_*'` | Managing products, team, settings | Server only, never in browser code |
|
|
19
19
|
|
|
20
20
|
```ts
|
|
21
21
|
import { BrainerceClient } from 'brainerce';
|
|
@@ -23,15 +23,15 @@ import { BrainerceClient } from 'brainerce';
|
|
|
23
23
|
const client = new BrainerceClient({ salesChannelId: 'vc_abc123' });
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
-
**If you pass more than one, `apiKey` wins, then `salesChannelId`, then `storeId`.** An `apiKey` puts the client in admin mode no matter what else you passed, so never add one "just to also read a channel"
|
|
26
|
+
**If you pass more than one, `apiKey` wins, then `salesChannelId`, then `storeId`.** An `apiKey` puts the client in admin mode no matter what else you passed, so never add one "just to also read a channel"; it changes every route the client calls. Passing none throws `BrainerceClient: either salesChannelId, apiKey, or storeId is required`.
|
|
27
27
|
|
|
28
|
-
Ask the client which mode it is in with `isSalesChannelMode()`, `isStorefrontMode()` or `isAdminMode()
|
|
28
|
+
Ask the client which mode it is in with `isSalesChannelMode()`, `isStorefrontMode()` or `isAdminMode()`. Exactly one returns `true`. (`isVibeCodedMode()` is a deprecated alias of `isSalesChannelMode()`.)
|
|
29
29
|
|
|
30
|
-
Not every method works in every mode. A handful
|
|
30
|
+
Not every method works in every mode. A handful (`getPaymentStatus()`, `confirmSdkPayment()`, `waitForOrder()`) are **sales-channel mode only** and throw `BrainerceError` 400 elsewhere. `storeId` mode is **not** read-only: it can create carts, run a checkout to a real order, and register/log in customers; what it cannot reach is the admin surface.
|
|
31
31
|
|
|
32
32
|
> **Building a storefront?** You only need your **Sales Channel ID** (`vc_*`) from the Brainerce dashboard under **Sales Channels**. No API key needed. API keys are a server-side admin secret.
|
|
33
33
|
>
|
|
34
|
-
> `connectionId` is the deprecated alias of `salesChannelId`. It still works
|
|
34
|
+
> `connectionId` is the deprecated alias of `salesChannelId`. It still works and logs a deprecation warning on every construction — it is a permanent backward-compat alias, not scheduled for removal. Use `salesChannelId` in new code.
|
|
35
35
|
|
|
36
36
|
## Installation
|
|
37
37
|
|
|
@@ -47,7 +47,7 @@ yarn add brainerce
|
|
|
47
47
|
|
|
48
48
|
## What You Must Build
|
|
49
49
|
|
|
50
|
-
Every Brainerce storefront must include **all mandatory features** below. Features auto-hide when the underlying capability is disabled, so build them all anyway
|
|
50
|
+
Every Brainerce storefront must include **all mandatory features** below. Features auto-hide when the underlying capability is disabled, so build them all anyway; they'll appear the moment the store owner enables them.
|
|
51
51
|
|
|
52
52
|
| Feature | SDK entry point | Mandatory |
|
|
53
53
|
| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- |
|
|
@@ -55,8 +55,9 @@ Every Brainerce storefront must include **all mandatory features** below. Featur
|
|
|
55
55
|
| Product detail with variant picker, stock, price | `client.getProductBySlug()` + helpers | ✅ |
|
|
56
56
|
| Buyer customization fields (engraving, uploads, select) | `product.customizationFields`, `client.uploadCustomizationFile()` | ✅ |
|
|
57
57
|
| Cart (add, update, remove, coupon, totals) | `client.addToCart()`, `getCartTotals(cart)` | ✅ |
|
|
58
|
-
| Inventory reservation countdown | Cart expiry timestamp from `client.getCart(cartId)`
|
|
58
|
+
| Inventory reservation countdown | Cart expiry timestamp from `client.getCart(cartId)` | ✅ |
|
|
59
59
|
| Full checkout end-to-end with payment | `setShippingAddress → selectShippingMethod → getPaymentProviders → pay → handlePaymentSuccess → waitForOrder` | ✅ |
|
|
60
|
+
| Gift card redemption at checkout | `client.applyGiftCard(checkoutId, code)`, `client.removeGiftCard(checkoutId, tenderId)`, `client.checkGiftCardBalance(code)` | conditional |
|
|
60
61
|
| Order confirmation (clear cart + wait for real order) | `client.handlePaymentSuccess()`, `client.waitForOrder()` | ✅ |
|
|
61
62
|
| Register + email verification flow | `client.registerCustomer()`, `client.verifyEmail()` | ✅ |
|
|
62
63
|
| Login + verification branch | `client.loginCustomer()` | ✅ |
|
|
@@ -66,7 +67,7 @@ Every Brainerce storefront must include **all mandatory features** below. Featur
|
|
|
66
67
|
| Loyalty & rewards (points balance + tiers + redeem) | `client.getLoyaltyStatus()`, `client.getAvailableRewards()`, `client.getRecommendedReward()`, `client.redeemLoyaltyReward(id)`, `client.reportSocialShare()` | conditional |
|
|
67
68
|
| Loyalty paid membership (premium subscription) | `client.getMembershipPlans()`, `client.getMySavedPaymentMethods()`, `client.subscribeToMembership(params)`, `client.cancelMembership()` | conditional |
|
|
68
69
|
| Embeddable loyalty widget (points + rewards on ANY site) | `client.getLoyaltyWidgetSession()` | conditional |
|
|
69
|
-
| Global header: cart count + search autocomplete | `client.smartGetCart()`, `client.getSearchSuggestions(query)`
|
|
70
|
+
| Global header: cart count + search autocomplete | `client.smartGetCart()`, `client.getSearchSuggestions(query)` | ✅ |
|
|
70
71
|
| Discount banners + product badges | `client.getDiscountBanners()`, `client.getProductDiscountBadge(productId)` | ✅ |
|
|
71
72
|
| Product reviews on PDP + JSON-LD aggregateRating | `client.listProductReviews(id)`, `client.submitProductReview(id, …)` | ✅ |
|
|
72
73
|
| Customer photos on reviews | `client.uploadReviewPhoto(productId, file)`, then `imageKeys` on submit | conditional |
|
|
@@ -74,6 +75,9 @@ Every Brainerce storefront must include **all mandatory features** below. Featur
|
|
|
74
75
|
| FAQ page | `client.content.faq.get('main', locale)` | conditional |
|
|
75
76
|
| Static pages catch-all (`/pages/[slug]`) | `client.content.page.getBySlug(slug, locale)` | conditional |
|
|
76
77
|
| Multi-language + RTL (when i18n enabled) | `client.setLocale()`, `client.getStoreDirection(locale)` | conditional |
|
|
78
|
+
| Donation page (only when `getStoreInfo().donationsEnabled`) | `client.createDonation(input)`, `client.getDonation(id)` | conditional |
|
|
79
|
+
|
|
80
|
+
**⛔ The donation page is the one row that does NOT auto-hide.** Every other conditional feature above renders nothing until the merchant configures it, which is why you build them all anyway. `createDonation` is *rejected* while donations are closed, so a donation page built for a store that has not opened them collects a donor's name, email and card details and then fails on submit. Gate that one on `getStoreInfo().donationsEnabled` and build nothing when it is false.
|
|
77
81
|
|
|
78
82
|
---
|
|
79
83
|
|
|
@@ -85,14 +89,15 @@ Violating any of these causes production incidents or broken orders. Read them b
|
|
|
85
89
|
|
|
86
90
|
- ALWAYS call SDK client methods. Never reconstruct REST URLs or call `fetch` directly.
|
|
87
91
|
- NEVER invent SDK method names. If it's not in this README or in `get-sdk-docs`, it doesn't exist.
|
|
88
|
-
- NEVER hardcode product data, categories, or store copy
|
|
89
|
-
- NEVER use `submitGuestOrder()`, `createGuestOrder()` or `createOrder()` on a store that takes payment
|
|
92
|
+
- NEVER hardcode product data, categories, or store copy. Brainerce is the database.
|
|
93
|
+
- NEVER use `submitGuestOrder()`, `createGuestOrder()` or `createOrder()` on a store that takes payment. They `POST /orders` directly, never touch `/payment/intent`, and produce an order nobody has paid for. They exist only for cash-on-delivery, manual-invoice and sandbox stores. Everything else goes through the checkout sequence below.
|
|
90
94
|
- ALWAYS use SDK helpers (`getCartTotals`, `formatPrice`, `getProductPriceInfo`, `getCartItemImage`, `getCartItemName`, `getVariantPrice`, `getStockStatus`, `getDescriptionContent`) instead of reading raw fields.
|
|
95
|
+
- ALWAYS read merchant settings from `client.getStoreCapabilities()` rather than hardcoding them. The low-stock threshold, the reservation timeout, whether back-in-stock alerts are offered and which optional features exist are all per sales channel. A hardcoded `5` is wrong on every store whose merchant chose something else. Call it once at app start and share the result; it is per channel, not per product. One exception, because `getStoreCapabilities()` is vibe-coded mode only: read `donationsEnabled` from `getStoreInfo()`, which works in every mode and carries the same fact as `features.hasDonations`.
|
|
91
96
|
|
|
92
97
|
### State management
|
|
93
98
|
|
|
94
99
|
- The SDK manages cart, checkout, and session state. Do NOT duplicate it in your own Redux/context.
|
|
95
|
-
- Product lists, categories, and inventory counts are NOT client state
|
|
100
|
+
- Product lists, categories, and inventory counts are NOT client state; fetch on demand.
|
|
96
101
|
- Discount rules and coupon validity are evaluated server-side. Never re-implement them client-side.
|
|
97
102
|
|
|
98
103
|
### Authentication
|
|
@@ -106,32 +111,49 @@ Violating any of these causes production incidents or broken orders. Read them b
|
|
|
106
111
|
### Checkout & orders
|
|
107
112
|
|
|
108
113
|
- The checkout sequence is strict: `setShippingAddress` → pick a shipping rate → `getPaymentProviders` → provider payment → `handlePaymentSuccess` → `waitForOrder`. Never skip or reorder.
|
|
109
|
-
- ALWAYS call `handlePaymentSuccess(checkoutId)` on the confirmation page
|
|
114
|
+
- ALWAYS call `handlePaymentSuccess(checkoutId)` on the confirmation page. It clears the cart so users don't see stale items.
|
|
110
115
|
- ALWAYS call `waitForOrder(checkoutId)` to poll for the real order before showing an order number. The payment callback may return before the order record exists.
|
|
111
|
-
- NEVER use the checkout total as the cart total
|
|
112
|
-
- The reservation timer is a hard guarantee
|
|
116
|
+
- NEVER use the checkout total as the cart total; they diverge (tax, shipping, discounts). Display `checkout.lineItems` on the summary, not `cart.items`.
|
|
117
|
+
- The reservation timer is a hard guarantee. Display the countdown from the cart and let the SDK handle expiry.
|
|
118
|
+
|
|
119
|
+
### Gift cards
|
|
120
|
+
|
|
121
|
+
- **A gift card is a means of payment, not a discount.** `applyGiftCard` does NOT change `checkout.total`, and tax stays calculated on the full order value. What drops is `checkout.providerAmountDue`, the amount the payment provider will be charged. Render the card on **its own line below the total** ("Gift card −₪54.50", then "Amount due ₪150.50"), never inside the discount block and never added to `discountAmount`. Folding it in understates the taxable base to the shopper and on their receipt.
|
|
122
|
+
- ALWAYS render applied cards from `checkout.tenders` (`{ tenderId, amountApplied }[]`), re-read with `getCheckout(checkoutId)`. A storefront that only remembers what `applyGiftCard` returned loses the card on a page reload while the hold is still live on the server, so the shopper applies it twice or is shown an amount the provider will not charge.
|
|
123
|
+
- ALWAYS remove with `removeGiftCard(checkoutId, tenderId)`, never by code. A checkout can carry several cards, and the code is never echoed back.
|
|
124
|
+
- NEVER try to tell refusals apart. An unknown code, an expired one, a spent one, a disabled one and one in the wrong currency all return the **same** HTTP 400 with the same message, on purpose: a response that distinguished them is an oracle for walking the code space. Show one message ("we can't use this code") and let the shopper re-type it. `checkGiftCardBalance` answers identically for unknown, disabled and expired cards.
|
|
125
|
+
- Apply and remove cards **before** you create the payment intent. Once the checkout is `PAYMENT_PENDING` / `PAYMENT_PROCESSING` these calls fail with `CHECKOUT_LOCKED`, which is what stops a card being applied behind a charge that was already quoted.
|
|
126
|
+
- NEVER subtract the card yourself when charging. `createPaymentIntent` already nets live gift cards off server-side; charge the intent's own `amount`.
|
|
127
|
+
- When `providerAmountDue` is `'0.00'` the cards cover the whole order. There is nothing for a provider to charge: skip the payment step and call `completeCheckout(checkoutId)` — it is allowed in exactly this case and produces a real paid order. **Then still clear the cart**, with `handlePaymentSuccess(checkoutId)`, exactly as you would after a payment. `completeCheckout` returns `{ orderId }`, so there is no `waitForOrder` poll to do, but skipping the cart clear leaves the shopper looking at items they have just bought.
|
|
128
|
+
- A card pays only in **its own currency**. There is no conversion, so a USD card is refused on an ILS checkout like any other unusable code.
|
|
129
|
+
|
|
130
|
+
### Donations
|
|
131
|
+
|
|
132
|
+
- NEVER route a donation through the cart or the checkout. A donation has no line item, no quantity, no shipping and no order, and it is reported separately from sales. It has its own pair, `createDonation` / `getDonation`. The tell that you have modelled it wrong is the amount: a cart cannot let a donor type one, so a "Donation $18" product is the wrong shape — and it files every gift into the merchant's sales figures.
|
|
133
|
+
- NEVER treat `createDonation` resolving as a completed gift. It returns `status: 'PENDING'` and a provider intent; the money has not moved. Complete the intent, then poll `getDonation(id)` for `PAID`. Nothing receipt-shaped before that.
|
|
134
|
+
- ALWAYS gate the donation page on `getStoreInfo().donationsEnabled`. Unlike every other conditional feature in this SDK it does NOT auto-hide: `createDonation` is rejected while donations are closed, so a page built early collects a donor's details and then fails.
|
|
113
135
|
|
|
114
136
|
### Token handling
|
|
115
137
|
|
|
116
|
-
- Customer auth tokens (`result.token` from `loginCustomer`/`registerCustomer`) should be passed to `client.setCustomerToken(token)`. The SDK stores session state internally. `setCustomerToken` is a plain setter
|
|
138
|
+
- Customer auth tokens (`result.token` from `loginCustomer`/`registerCustomer`) should be passed to `client.setCustomerToken(token)`. The SDK stores session state internally. `setCustomerToken` is a plain setter, so always follow it with `await client.syncCartOnLogin()`, or the shopper's guest cart is never attached to their account and identity-keyed features (first-order discounts, per-customer usage caps, abandoned-cart recovery) misbehave.
|
|
117
139
|
- NEVER put the admin API key (`brainerce_*`) in client code. It is a server-only secret.
|
|
118
140
|
- OAuth callbacks arrive with a one-time `auth_code` URL param. Call `client.exchangeOAuthCode(authCode)` to swap it for the JWT, apply via `setCustomerToken`, then call `client.syncCartOnLogin()` to claim the guest cart. (The legacy `?token=` URL param is still emitted for backward compatibility but will be removed in the next major release.)
|
|
119
141
|
|
|
120
142
|
### i18n
|
|
121
143
|
|
|
122
|
-
- NEVER hardcode currency, locale, or language strings
|
|
123
|
-
- NEVER format prices with `toFixed(2)
|
|
124
|
-
- When i18n is enabled, call `client.setLocale(locale)` at app init and include a language switcher. For RTL locales (`he`, `ar`), set `<html dir="rtl">`
|
|
125
|
-
- Call `setLocale()` **before `createCheckout()
|
|
144
|
+
- NEVER hardcode currency, locale, or language strings; read them from `getStoreInfo()`.
|
|
145
|
+
- NEVER format prices with `toFixed(2)`; use `formatPrice()` from the SDK.
|
|
146
|
+
- When i18n is enabled, call `client.setLocale(locale)` at app init and include a language switcher. For RTL locales (`he`, `ar`), set `<html dir="rtl">` and do NOT add `flex-row-reverse` on top.
|
|
147
|
+
- Call `setLocale()` **before `createCheckout()`**, because the order captures the active locale (`order.locale`), which drives the confirmation-email language AND the product names shown in order history. Without it, orders and their emails fall back to the store default language. Search also needs the locale active to match translated names.
|
|
126
148
|
- Built-in order emails are localized for English and Hebrew; for other languages the merchant must add a custom email template per language (otherwise the email falls back to English).
|
|
127
149
|
|
|
128
150
|
### Type safety
|
|
129
151
|
|
|
130
152
|
- NEVER use `as any` or `as unknown as`. Fix the type, don't hide it.
|
|
131
153
|
- NEVER write your own copies of SDK types (Cart, Product, Order). Import from `'brainerce'`.
|
|
132
|
-
- All prices are **STRINGS
|
|
154
|
+
- All prices are **STRINGS**, so always `parseFloat()` before math or comparisons.
|
|
133
155
|
- `CartItem` / `CheckoutLineItem` = **NESTED** (`item.product.name`, `item.unitPrice`). `OrderItem` = **FLAT** (`item.name`, `item.price`). Not interchangeable.
|
|
134
|
-
- `Cart` has no `.total` field
|
|
156
|
+
- `Cart` has no `.total` field; call `getCartTotals(cart)`.
|
|
135
157
|
|
|
136
158
|
---
|
|
137
159
|
|
|
@@ -141,7 +163,7 @@ These sequences are non-negotiable. The order of SDK calls matters.
|
|
|
141
163
|
|
|
142
164
|
### Checkout flow
|
|
143
165
|
|
|
144
|
-
1. Collect customer email, billing address, shipping address (`line1`, `line2`, `city`, `region`, `postalCode`, `country`). `email` is required. Include an optional **"Order notes"** textarea by default
|
|
166
|
+
1. Collect customer email, billing address, shipping address (`line1`, `line2`, `city`, `region`, `postalCode`, `country`). `email` is required. Include an optional **"Order notes"** textarea by default; its value lands on the order for the merchant.
|
|
145
167
|
2. Submit address to get shipping rates:
|
|
146
168
|
```ts
|
|
147
169
|
const { checkout, rates } = await client.setShippingAddress(checkoutId, {
|
|
@@ -162,13 +184,13 @@ These sequences are non-negotiable. The order of SDK calls matters.
|
|
|
162
184
|
await client.selectShippingMethod(checkoutId, rateId);
|
|
163
185
|
```
|
|
164
186
|
Label each rate with `rate.speedTier` (`'cheapest' | 'balanced' | 'fastest'`) and
|
|
165
|
-
`rate.estimatedDays
|
|
187
|
+
`rate.estimatedDays`, **not** `rate.name`, which for live carrier rates is the
|
|
166
188
|
carrier's own service code. Manual zone rates carry no `speedTier`; use their `name`.
|
|
167
189
|
4. Fetch available payment providers:
|
|
168
190
|
```ts
|
|
169
191
|
const providers = await client.getPaymentProviders();
|
|
170
192
|
```
|
|
171
|
-
Each provider has a `renderType
|
|
193
|
+
Each provider has a `renderType`: `'sdk-widget'` (Stripe, PayPal, Grow), `'iframe'` (Cardcom), `'redirect'`, `'sandbox'`. Branch on `renderType`, never on provider name.
|
|
172
194
|
5. Confirm payment using the provider's flow (Stripe Elements `stripe.confirmCardPayment`, PayPal button, redirect, etc.).
|
|
173
195
|
6. On the confirmation page, **always call both**:
|
|
174
196
|
```ts
|
|
@@ -177,6 +199,56 @@ These sequences are non-negotiable. The order of SDK calls matters.
|
|
|
177
199
|
```
|
|
178
200
|
7. Display `checkout.lineItems` (not `cart.items`) on the order summary.
|
|
179
201
|
|
|
202
|
+
If the store has gift cards on, the redemption field goes between step 3 and step 4 — see the flow below.
|
|
203
|
+
|
|
204
|
+
### Gift card redemption flow
|
|
205
|
+
|
|
206
|
+
Conditional: in `salesChannelId` mode, build it when `getStoreCapabilities().features.hasGiftCards` is true. That call is channel-only and `getStoreInfo()` carries no gift-card flag, so in `storeId` and admin mode there is no switch to read — build the field anyway; a code on a store without cards is just refused. It sits **inside** the checkout, after shipping is picked and **before** the payment intent, because applying a card changes what the provider is asked for.
|
|
207
|
+
|
|
208
|
+
1. Offer the field on the checkout page (optionally with a "check balance" affordance):
|
|
209
|
+
|
|
210
|
+
```ts
|
|
211
|
+
const { balance, currency, usable } = await client.checkGiftCardBalance(code);
|
|
212
|
+
// usable === false for an unknown, disabled OR expired card — all identical, by design.
|
|
213
|
+
// Never render "expired" or "not found"; you do not know which it was.
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
2. Apply it. The card is a **tender**, so the total does not move:
|
|
217
|
+
|
|
218
|
+
```ts
|
|
219
|
+
const { tenderId, amountApplied, providerAmountDue } = await client.applyGiftCard(
|
|
220
|
+
checkoutId,
|
|
221
|
+
code
|
|
222
|
+
);
|
|
223
|
+
// checkout.total is UNCHANGED. providerAmountDue is what the card leaves for the provider.
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Every refusal is one HTTP 400 with one message. Show a single "we can't use this code" and let the shopper re-enter it.
|
|
227
|
+
|
|
228
|
+
3. Re-read the checkout and render from it, never from what you remembered:
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
const checkout = await client.getCheckout(checkoutId);
|
|
232
|
+
checkout.tenders; // [{ tenderId, amountApplied }] — oldest first, survives a reload
|
|
233
|
+
checkout.providerAmountDue; // '0.00' means nothing is owed
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Summary order: subtotal → discounts → shipping → tax → **total** → one line per gift card → **amount due**.
|
|
237
|
+
|
|
238
|
+
4. Removing takes the `tenderId`, never the code — a checkout can carry several cards:
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
const { providerAmountDue } = await client.removeGiftCard(checkoutId, tenderId);
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Nothing was ever debited, so the held value goes straight back to the card.
|
|
245
|
+
|
|
246
|
+
5. Then branch on what is still owed:
|
|
247
|
+
- `providerAmountDue > '0.00'` → the normal payment step. `createPaymentIntent` already nets the cards off; charge the intent's `amount` and never subtract anything yourself.
|
|
248
|
+
- `providerAmountDue === '0.00'` → there is no charge to make. Skip the provider entirely and call `completeCheckout(checkoutId)`, then `handlePaymentSuccess(checkoutId)` to clear the cart. It returns `{ orderId }` directly, so no `waitForOrder` poll is needed here.
|
|
249
|
+
|
|
250
|
+
⛔ Do all of this **before** creating the payment intent. Once the checkout is `PAYMENT_PENDING` / `PAYMENT_PROCESSING`, apply and remove both fail with `CHECKOUT_LOCKED`.
|
|
251
|
+
|
|
180
252
|
### Registration flow
|
|
181
253
|
|
|
182
254
|
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.
|
|
@@ -192,7 +264,7 @@ These sequences are non-negotiable. The order of SDK calls matters.
|
|
|
192
264
|
4. On verify-email: collect 6-digit code → `client.verifyEmail(code)`. Offer resend via `client.resendVerificationEmail()`.
|
|
193
265
|
5. After `verifyEmail` resolves: `client.setCustomerToken(result.token)`, then `await client.syncCartOnLogin()`, route to account.
|
|
194
266
|
|
|
195
|
-
> Build the verify-email step even if verification is currently disabled
|
|
267
|
+
> Build the verify-email step even if verification is currently disabled; it auto-hides.
|
|
196
268
|
|
|
197
269
|
### Login flow
|
|
198
270
|
|
|
@@ -204,8 +276,8 @@ These sequences are non-negotiable. The order of SDK calls matters.
|
|
|
204
276
|
3. Branch on `result.requiresVerification`:
|
|
205
277
|
- `true` → route to verify-email
|
|
206
278
|
- `false` → `client.setCustomerToken(result.token)`, then `await client.syncCartOnLogin()`, route to previous page or account
|
|
207
|
-
4. Always offer OAuth buttons from `client.getAvailableOAuthProviders()
|
|
208
|
-
5. Render specific errors (bad credentials, rate limited, disabled)
|
|
279
|
+
4. Always offer OAuth buttons from `client.getAvailableOAuthProviders()`. Render the region even when empty; it auto-populates when a provider is enabled.
|
|
280
|
+
5. Render specific errors (bad credentials, rate limited, disabled). Never swallow them.
|
|
209
281
|
|
|
210
282
|
### Order confirmation flow
|
|
211
283
|
|
|
@@ -226,9 +298,10 @@ These sequences are non-negotiable. The order of SDK calls matters.
|
|
|
226
298
|
```
|
|
227
299
|
|
|
228
300
|
Branch on `mode` only for what you render: a `'partial'` result means the shopper still has a cart worth linking to. Never gate `waitForOrder` on `cleared`.
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
301
|
+
|
|
302
|
+
3. `const result = await client.waitForOrder(checkoutId)` polls until the webhook writes the order. `result.status.orderNumber` / `result.status.orderId` are available on success.
|
|
303
|
+
4. Show a spinner during step 3 (webhook may lag). On timeout: show "we're still processing, check your email" with a link to order history, where the order WILL appear.
|
|
304
|
+
5. On success: render the order number, or, if your design wants more than that, fetch full details:
|
|
232
305
|
|
|
233
306
|
```typescript
|
|
234
307
|
const result = await client.waitForOrder(checkoutId);
|
|
@@ -240,7 +313,7 @@ if (result.success) {
|
|
|
240
313
|
}
|
|
241
314
|
```
|
|
242
315
|
|
|
243
|
-
`getOrderByCheckout` works for guests too
|
|
316
|
+
`getOrderByCheckout` works for guests too, because possession of the checkout id is
|
|
244
317
|
the credential, no customer token needed.
|
|
245
318
|
|
|
246
319
|
### Password reset flow
|
|
@@ -274,7 +347,7 @@ the credential, no customer token needed.
|
|
|
274
347
|
// then redirect to account
|
|
275
348
|
}
|
|
276
349
|
```
|
|
277
|
-
The legacy `?token=` URL param is still emitted for backward compatibility but will be removed in the next major release
|
|
350
|
+
The legacy `?token=` URL param is still emitted for backward compatibility but will be removed in the next major release. Migrate to `auth_code` now.
|
|
278
351
|
4. On failure the browser lands on **the same `redirectUrl`** (never on the API host), carrying `oauth_error` + `error_description`:
|
|
279
352
|
```ts
|
|
280
353
|
const oauthError = params.get('oauth_error') as OAuthErrorCode | null;
|
|
@@ -288,16 +361,16 @@ the credential, no customer token needed.
|
|
|
288
361
|
}
|
|
289
362
|
}
|
|
290
363
|
```
|
|
291
|
-
The code list is open
|
|
364
|
+
The code list is open: the provider's own codes (`access_denied`, …) pass through, so always handle the default case.
|
|
292
365
|
|
|
293
366
|
> Build the OAuth button region AND the callback handler even when no providers are configured.
|
|
294
367
|
|
|
295
368
|
### Inventory reservation flow
|
|
296
369
|
|
|
297
|
-
- Display the countdown from `cart.reservation?.expiresAt
|
|
370
|
+
- Display the countdown from `cart.reservation?.expiresAt`, refreshing once per second (`reservation` is optional; only present when a reservation strategy is active).
|
|
298
371
|
- On expiry: call `client.getCart(cartId)` to refresh, or `client.smartGetCart()` when you are not tracking a cart id yourself. `getCart` takes the cart id; there is no no-argument form. Items whose reservation expired are flagged server-side.
|
|
299
372
|
- On the checkout page: if reservation has expired, block payment and show "your cart has expired" with a link back to cart.
|
|
300
|
-
- Do NOT implement your own timer logic
|
|
373
|
+
- Do NOT implement your own timer logic; the SDK is the source of truth.
|
|
301
374
|
|
|
302
375
|
---
|
|
303
376
|
|
|
@@ -305,33 +378,33 @@ the credential, no customer token needed.
|
|
|
305
378
|
|
|
306
379
|
The SDK exports these utility functions for common UI tasks:
|
|
307
380
|
|
|
308
|
-
| Function | Purpose
|
|
309
|
-
| ---------------------------------------------- |
|
|
310
|
-
| `formatPrice(amount, { currency?, locale? })` | Format prices for display
|
|
311
|
-
| `getPriceDisplay(amount, currency?, locale?)` | Alias for `formatPrice`
|
|
312
|
-
| `getDescriptionContent(product)` | Get product description (HTML or text)
|
|
313
|
-
| `isHtmlDescription(product)` | Check if description is HTML
|
|
314
|
-
| `getStockStatus(inventory)`
|
|
315
|
-
| `getProductPrice(product)` | Get effective price (handles sales)
|
|
316
|
-
| `getProductPriceInfo(product)` | Get price + sale info + discount % (falls back to `priceMin` when `basePrice=0` on VARIABLE)
|
|
317
|
-
| `getVariantPrice(variant, basePrice)` | Get variant price with fallback
|
|
318
|
-
| `getCartTotals(cart, shippingPrice?)` | Calculate cart subtotal/discount/total
|
|
319
|
-
| `getCartItemName(item)` | Get name from nested cart item (product + variant)
|
|
320
|
-
| `getCartItemImage(item)` | Get image URL from cart item
|
|
321
|
-
| `getVariantOptions(variant)` | Get variant attributes as array
|
|
322
|
-
| `isCouponApplicableToProduct(coupon, product)` | Check if coupon applies
|
|
323
|
-
| `isAllowedPaymentUrl(url, options?)` | Validate a payment URL host
|
|
324
|
-
| `safePaymentRedirect(url, options?)` | Validate then `window.location.href`
|
|
325
|
-
| `buildProductJsonLd(product, opts)` | schema.org Product JSON-LD (PDPs only)
|
|
326
|
-
| `buildArticleJsonLd(post, opts)` | schema.org Article JSON-LD for blog posts
|
|
327
|
-
| `buildOrganizationJsonLd(store, opts)` | schema.org Organization for the homepage
|
|
328
|
-
| `buildBreadcrumbJsonLd(items)` | schema.org BreadcrumbList
|
|
329
|
-
| `buildProductFaqJsonLd(product)` | schema.org FAQPage from `product.faq` (null when empty)
|
|
330
|
-
| `jsonLdScriptProps(data)` | XSS-safe `<script type="application/ld+json">` props
|
|
331
|
-
| `getBlogSitemapEntries(client, opts)` | Paginate published posts into sitemap entries
|
|
332
|
-
| `getProductSitemapEntries(client, opts)` | ALL published products into sitemap entries (no 100-item clamp)
|
|
333
|
-
| `getCategorySitemapEntries(client, opts)` | Category tree into sitemap entries
|
|
334
|
-
| `client.resolveSlugRedirect(type, slug)` | Renamed slug → current slug (301 support in not-found paths)
|
|
381
|
+
| Function | Purpose | Example |
|
|
382
|
+
| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
|
|
383
|
+
| `formatPrice(amount, { currency?, locale? })` | Format prices for display | `formatPrice("99.99", { currency: 'USD' })` → `$99.99` |
|
|
384
|
+
| `getPriceDisplay(amount, currency?, locale?)` | Alias for `formatPrice` | Same as above |
|
|
385
|
+
| `getDescriptionContent(product)` | Get product description (HTML or text) | `getDescriptionContent(product)` |
|
|
386
|
+
| `isHtmlDescription(product)` | Check if description is HTML | `isHtmlDescription(product)` → `true/false` |
|
|
387
|
+
| `getStockStatus(inventory, opts?)` | Human-readable stock status. ⛔ `lowStockThreshold` defaults to `0`, so it NEVER says "Low Stock" until you pass the merchant's value | `getStockStatus(inventory, { lowStockThreshold })` → `"Low Stock"` |
|
|
388
|
+
| `getProductPrice(product)` | Get effective price (handles sales) | `getProductPrice(product)` → `29.99` |
|
|
389
|
+
| `getProductPriceInfo(product)` | Get price + sale info + discount % (falls back to `priceMin` when `basePrice=0` on VARIABLE) | `{ price, isOnSale, discountPercent }` |
|
|
390
|
+
| `getVariantPrice(variant, basePrice)` | Get variant price with fallback | `getVariantPrice(variant, '29.99')` → `34.99` |
|
|
391
|
+
| `getCartTotals(cart, shippingPrice?)` | Calculate cart subtotal/discount/total | `{ subtotal, discount, shipping, total }` |
|
|
392
|
+
| `getCartItemName(item)` | Get name from nested cart item (product + variant) | `getCartItemName(item)` → `"Blue T-Shirt - Large"` |
|
|
393
|
+
| `getCartItemImage(item)` | Get image URL from cart item | `getCartItemImage(item)` → `"https://..."` |
|
|
394
|
+
| `getVariantOptions(variant)` | Get variant attributes as array | `[{ name: "Color", value: "Red" }]` |
|
|
395
|
+
| `isCouponApplicableToProduct(coupon, product)` | Check if coupon applies | `isCouponApplicableToProduct(coupon, product)` |
|
|
396
|
+
| `isAllowedPaymentUrl(url, options?)` | Validate a payment URL host | `isAllowedPaymentUrl(intent.clientSecret)` → `true` |
|
|
397
|
+
| `safePaymentRedirect(url, options?)` | Validate then `window.location.href` | `safePaymentRedirect(intent.clientSecret)` |
|
|
398
|
+
| `buildProductJsonLd(product, opts)` | schema.org Product JSON-LD (PDPs only) | See SEO section |
|
|
399
|
+
| `buildArticleJsonLd(post, opts)` | schema.org Article JSON-LD for blog posts | See SEO section |
|
|
400
|
+
| `buildOrganizationJsonLd(store, opts)` | schema.org Organization for the homepage | See SEO section |
|
|
401
|
+
| `buildBreadcrumbJsonLd(items)` | schema.org BreadcrumbList | See SEO section |
|
|
402
|
+
| `buildProductFaqJsonLd(product)` | schema.org FAQPage from `product.faq` (null when empty); render the same pairs as visible text | `const faq = buildProductFaqJsonLd(product)` |
|
|
403
|
+
| `jsonLdScriptProps(data)` | XSS-safe `<script type="application/ld+json">` props | `<script {...jsonLdScriptProps(data)} />` |
|
|
404
|
+
| `getBlogSitemapEntries(client, opts)` | Paginate published posts into sitemap entries | See SEO section |
|
|
405
|
+
| `getProductSitemapEntries(client, opts)` | ALL published products into sitemap entries (no 100-item clamp) | See SEO section |
|
|
406
|
+
| `getCategorySitemapEntries(client, opts)` | Category tree into sitemap entries | See SEO section |
|
|
407
|
+
| `client.resolveSlugRedirect(type, slug)` | Renamed slug → current slug (301 support in not-found paths) | See SEO section |
|
|
335
408
|
|
|
336
409
|
```typescript
|
|
337
410
|
import {
|
|
@@ -351,8 +424,11 @@ const priceText = formatPrice(product.basePrice, { currency: 'USD' }); // "$99.9
|
|
|
351
424
|
// Get product description (handles HTML vs plain text)
|
|
352
425
|
const description = getDescriptionContent(product);
|
|
353
426
|
|
|
354
|
-
// Get stock status text
|
|
355
|
-
|
|
427
|
+
// Get stock status text. Pass the merchant's threshold, or it never says
|
|
428
|
+
// "Low Stock": the option defaults to 0, which disables the low-stock state.
|
|
429
|
+
const caps = await client.getStoreCapabilities();
|
|
430
|
+
const lowStockThreshold = caps.connection.lowStockWarning ? caps.connection.lowStockThreshold : 0; // the merchant switched low-stock messaging off
|
|
431
|
+
const stockText = getStockStatus(product.inventory, { lowStockThreshold }); // "In Stock", "Low Stock", "Out of Stock"
|
|
356
432
|
|
|
357
433
|
// Get effective price (handles sale prices automatically)
|
|
358
434
|
const price = getProductPrice(product); // Returns number: 29.99
|
|
@@ -383,8 +459,9 @@ const itemImage = getCartItemImage(cartItem); // "https://..."
|
|
|
383
459
|
const { hasPayments, providers } = await client.getPaymentProviders();
|
|
384
460
|
|
|
385
461
|
if (!hasPayments) {
|
|
386
|
-
//
|
|
387
|
-
|
|
462
|
+
// NORMAL for a new store, not an error. Build the rest of checkout anyway and
|
|
463
|
+
// scope this notice to the payment step; never ship a disabled Pay button.
|
|
464
|
+
return <div>Payment is not set up for this store yet</div>;
|
|
388
465
|
}
|
|
389
466
|
|
|
390
467
|
// Show payment forms for available providers
|
|
@@ -414,12 +491,12 @@ const { data: products } = await client.getProducts();
|
|
|
414
491
|
|
|
415
492
|
### Product customization fields (buyer input)
|
|
416
493
|
|
|
417
|
-
Products can expose `customizationFields
|
|
494
|
+
Products can expose `customizationFields`, the merchant-defined inputs the buyer fills on the product page (engraving text, photo upload, select / multi-select options, date pickers, etc.). Render the form from the array, upload any images via `uploadCustomizationFile()`, then pass values as `metadata` on add-to-cart. The server validates and snapshots everything onto the order line. Definitions flagged `appliesToAllProducts: true` are folded into every product's `customizationFields` automatically, with no client-side merging required.
|
|
418
495
|
|
|
419
496
|
```typescript
|
|
420
497
|
if (product.customizationFields?.length) {
|
|
421
498
|
// Render a form control per field using field.type (TEXT, SELECT,
|
|
422
|
-
// MULTI_SELECT, IMAGE, GALLERY, DATE, ...) — see
|
|
499
|
+
// MULTI_SELECT, IMAGE, GALLERY, DATE, ...) — see the Core Integration guide §2.8
|
|
423
500
|
}
|
|
424
501
|
|
|
425
502
|
// For IMAGE / GALLERY fields: upload first
|
|
@@ -441,7 +518,7 @@ Full rendering guide + per-type validation rules: [Core Integration §2.8](https
|
|
|
441
518
|
|
|
442
519
|
### Modifier groups (restaurant / build-your-own products)
|
|
443
520
|
|
|
444
|
-
Products can expose `modifierGroups
|
|
521
|
+
Products can expose `modifierGroups`, the merchant-defined option blocks like "Toppings" (max 8, first 3 free) or "Sauce" (pick exactly one). Render radios for `selectionType: 'SINGLE'` and checkboxes for `'MULTIPLE'`, honor `defaultModifierIds` and `isDefault` on first render, disable modifiers with `available: false`, and pass selections on add-to-cart. The server is the source of truth for free-allocation and final pricing.
|
|
445
522
|
|
|
446
523
|
```typescript
|
|
447
524
|
// 5-line add-to-cart with modifiers
|
|
@@ -455,7 +532,7 @@ await client.addToCart(cart.id, {
|
|
|
455
532
|
});
|
|
456
533
|
```
|
|
457
534
|
|
|
458
|
-
Money on the wire is **always strings** (`priceDelta: "5.00"`). Validation failures arrive as a structured 400 envelope on `BrainerceError.details` with `code: 'MODIFIER_VALIDATION_FAILED'`; the per-issue list is nested at `details.errors[]`, so from the SDK it reads `err.details.details.errors` (`err.details` is the whole response body)
|
|
535
|
+
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 [Rules & Reference "Modifier validation errors"](https://brainerce.com/docs/integration/rules) for the full code list.
|
|
459
536
|
|
|
460
537
|
Full rendering guide: [Core Integration §2.9](https://brainerce.com/docs/integration/core). Restaurant features (scheduled availability, nested combos to depth 3, downsell modifiers): [Optional Features "Restaurant / build-your-own products"](https://brainerce.com/docs/integration/optional).
|
|
461
538
|
|
|
@@ -479,22 +556,22 @@ const page = await client.content.page.getBySlug(params.slug, locale);
|
|
|
479
556
|
if (!page) notFound();
|
|
480
557
|
```
|
|
481
558
|
|
|
482
|
-
All `get` / `getBySlug` return `null` on 404
|
|
559
|
+
All `get` / `getBySlug` return `null` on 404. Render a hard-coded fallback so the page never crashes when the merchant hasn't seeded yet.
|
|
483
560
|
|
|
484
|
-
**Security**: `FAQ.items[i].answer`, `RICH_TEXT.html`, `PAGE.html`, and `Product.description` are **merchant-authored HTML**. The server does NOT pre-sanitize FAQ/RICH_TEXT/PAGE (merchants may embed iframes); `Product.description` is server-sanitized on write but you still sanitize on render. `Product.description` may contain `<video>` and host-locked YouTube/Vimeo `<iframe>` embeds
|
|
561
|
+
**Security**: `FAQ.items[i].answer`, `RICH_TEXT.html`, `PAGE.html`, and `Product.description` are **merchant-authored HTML**. The server does NOT pre-sanitize FAQ/RICH_TEXT/PAGE (merchants may embed iframes); `Product.description` is server-sanitized on write but you still sanitize on render. `Product.description` may contain `<video>` and host-locked YouTube/Vimeo `<iframe>` embeds, so allow those tags (iframe restricted to `www.youtube.com` / `www.youtube-nocookie.com` / `player.vimeo.com`) and add those hosts to your CSP `frame-src`. ALWAYS sanitize before injecting:
|
|
485
562
|
|
|
486
563
|
```typescript
|
|
487
564
|
import DOMPurify from 'isomorphic-dompurify';
|
|
488
565
|
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(rawHtml) }} />
|
|
489
566
|
```
|
|
490
567
|
|
|
491
|
-
The `create-brainerce-store` scaffold ships ready-made components (`<AnnouncementBar>`, `<SiteHeader>`, `<SiteFooter>`, `<FaqSection>`, `<RichTextBlock>`) + a `/pages/[slug]` catch-all route
|
|
568
|
+
The `create-brainerce-store` scaffold ships ready-made components (`<AnnouncementBar>`, `<SiteHeader>`, `<SiteFooter>`, `<FaqSection>`, `<RichTextBlock>`) + a `/pages/[slug]` catch-all route. Use them rather than rolling your own renderers.
|
|
492
569
|
|
|
493
570
|
Full guide: [Core Integration "Content"](https://brainerce.com/docs/integration/core#content). Advanced patterns (channel scoping, custom fields, translations, admin writes): [Optional Features "Content"](https://brainerce.com/docs/integration/optional). Validation + sanitize rules: [Rules & Reference "Content"](https://brainerce.com/docs/integration/rules).
|
|
494
571
|
|
|
495
572
|
### Blog
|
|
496
573
|
|
|
497
|
-
Merchants publish blog posts in **Content → Blog**. Storefronts choose their own URL scheme
|
|
574
|
+
Merchants publish blog posts in **Content → Blog**. Storefronts choose their own URL scheme: render posts at `/blog/[slug]`, `/articles/[slug]`, or whatever fits the brand.
|
|
498
575
|
|
|
499
576
|
```typescript
|
|
500
577
|
// List published posts (any SDK mode)
|
|
@@ -519,11 +596,11 @@ return <div dangerouslySetInnerHTML={{ __html: safeHtml }} className="prose" />;
|
|
|
519
596
|
|
|
520
597
|
**Scheduling**: A post is visible once `status === 'PUBLISHED'` and `publishedAt <= now()`. Set a future `publishedAt` when publishing to schedule.
|
|
521
598
|
|
|
522
|
-
**SEO Autopilot writes here too**: the platform's SEO Autopilot publishes AI-written articles into this same blog automatically
|
|
599
|
+
**SEO Autopilot writes here too**: the platform's SEO Autopilot publishes AI-written articles into this same blog automatically. Render whatever `getPosts()` returns, and see the SEO section below for the required discoverability pieces.
|
|
523
600
|
|
|
524
|
-
### SEO
|
|
601
|
+
### SEO: JSON-LD builders, sitemap helpers, IndexNow key, llms.txt + agents.md
|
|
525
602
|
|
|
526
|
-
The SDK ships schema.org builders that encode Google's structured-data rules (aggregateRating gated on `reviewCount > 0` with explicit `bestRating`/`worstRating`, AggregateOffer with `offerCount` for VARIABLE products, XSS-safe serialization, and the full availability mapping
|
|
603
|
+
The SDK ships schema.org builders that encode Google's structured-data rules (aggregateRating gated on `reviewCount > 0` with explicit `bestRating`/`worstRating`, AggregateOffer with `offerCount` for VARIABLE products, XSS-safe serialization, and the full availability mapping: `InStock` from the backend's pre-computed `inventory.inStock`, `BackOrder` for purchasable-while-out-of-stock products, `OutOfStock` otherwise). `buildProductJsonLd`'s Offer also always includes `itemCondition` (hardcoded `NewCondition`, for a first-party new-goods catalog), `priceValidUntil` when the product has an active sale-price window (`salePriceEndsAt`), and `shippingDetails` when you pass `shipping` (real flat-rate/free zones from `storeInfo.shipping`, omitted entirely and never fabricated if you don't pass it). Prefer these builders over hand-rolled JSON-LD:
|
|
527
604
|
|
|
528
605
|
```tsx
|
|
529
606
|
import {
|
|
@@ -553,7 +630,7 @@ import {
|
|
|
553
630
|
}))} />
|
|
554
631
|
```
|
|
555
632
|
|
|
556
|
-
**Product + category + blog entries in sitemap.xml** (required). ⚠️ Products **must** use `getProductSitemapEntries
|
|
633
|
+
**Product + category + blog entries in sitemap.xml** (required). ⚠️ Products **must** use `getProductSitemapEntries`, because the public listing API clamps `limit` to 100, so a naive `getProducts({ limit: 1000 })` sitemap silently truncates at 100 products. The helper uses a dedicated lightweight endpoint (slug + updatedAt + localeSlugs, up to 5000 in one call) and falls back to pagination on older backends:
|
|
557
634
|
|
|
558
635
|
```ts
|
|
559
636
|
// app/sitemap.ts
|
|
@@ -581,15 +658,15 @@ const blogPages = await getBlogSitemapEntries(client, {
|
|
|
581
658
|
return [...staticPages, ...productPages, ...categoryPages, ...blogPages];
|
|
582
659
|
```
|
|
583
660
|
|
|
584
|
-
**robots.txt** (required): allow the AI search crawlers by name (`OAI-SearchBot`, `ChatGPT-User`, `Claude-SearchBot`, `Claude-User`, `PerplexityBot`, `Perplexity-User`, `Bingbot`, `Applebot`, `Amazonbot`)
|
|
661
|
+
**robots.txt** (required): allow the AI search crawlers by name (`OAI-SearchBot`, `ChatGPT-User`, `Claude-SearchBot`, `Claude-User`, `PerplexityBot`, `Perplexity-User`, `Bingbot`, `Applebot`, `Amazonbot`); they power ChatGPT/Claude/Perplexity/Copilot shopping answers and respect robots.txt. Keep `/api/`, `/auth/`, `/checkout/`, `/account/` disallowed.
|
|
585
662
|
|
|
586
663
|
**IndexNow key file** (required): the platform pings IndexNow when posts publish; search engines verify by fetching `GET /indexnow-key.txt`. Serve `getStoreInfo().seo.indexNowKey` as `text/plain`, 404 while `null`. The key is **not a secret** (public by protocol design).
|
|
587
664
|
|
|
588
|
-
**llms.txt + agents.md** (required): `/llms.txt` is a plain-text site summary (store name, categories, key pages, recent article links) for AI answer engines; `/agents.md` is the agent-facing guide (machine surfaces, key URLs, currency, how buying works). Multi-locale stores: keep these dotted routes (plus `indexnow-key.txt`) at the app ROOT
|
|
665
|
+
**llms.txt + agents.md** (required): `/llms.txt` is a plain-text site summary (store name, categories, key pages, recent article links) for AI answer engines; `/agents.md` is the agent-facing guide (machine surfaces, key URLs, currency, how buying works). Multi-locale stores: keep these dotted routes (plus `indexnow-key.txt`) at the app ROOT, because locale middleware matchers skip dotted paths, so a locale-nested copy serves the homepage HTML instead.
|
|
589
666
|
|
|
590
667
|
**Site verification** : when `getStoreInfo().seo.googleSiteVerification` is set, render `<meta name="google-site-verification" content={token} />` in the root layout head (Search Console verification + Merchant Center website claim).
|
|
591
668
|
|
|
592
|
-
**Renamed slugs 301 instead of 404** (required): the platform records every product/blog slug rename. In the not-found path of the product and blog pages call `client.resolveSlugRedirect('product' | 'blog', slug)
|
|
669
|
+
**Renamed slugs 301 instead of 404** (required): the platform records every product/blog slug rename. In the not-found path of the product and blog pages call `client.resolveSlugRedirect('product' | 'blog', slug)`. On a hit, `permanentRedirect()` to the returned `currentSlug`; `null` means a genuine 404 (never throws, safe to call unconditionally). Rename chains collapse to one hop.
|
|
593
670
|
|
|
594
671
|
---
|
|
595
672
|
|
|
@@ -784,7 +861,7 @@ const address: SetShippingAddressDto = {
|
|
|
784
861
|
```
|
|
785
862
|
|
|
786
863
|
**And no coordinates.** The address endpoints validate against a strict
|
|
787
|
-
allow-list
|
|
864
|
+
allow-list: one property that isn't on the DTO rejects the whole call with
|
|
788
865
|
`400 "property lat should not exist"`, which doesn't degrade: it blocks the
|
|
789
866
|
address step for every shopper. `getAddressDetails()` resolves an address
|
|
790
867
|
carrying `lat`, `lng` and `formattedAddress`, so this is the one that bites:
|
|
@@ -810,8 +887,8 @@ await client.setShippingAddress(checkoutId, {
|
|
|
810
887
|
});
|
|
811
888
|
```
|
|
812
889
|
|
|
813
|
-
Use `address.lat` / `address.lng` for your own UI
|
|
814
|
-
readout
|
|
890
|
+
Use `address.lat` / `address.lng` for your own UI (a map pin, a distance
|
|
891
|
+
readout) and nothing else.
|
|
815
892
|
|
|
816
893
|
### 10. OAuth - Use `authorizationUrl`, NOT `url`
|
|
817
894
|
|
|
@@ -890,7 +967,7 @@ const total = subtotal - discount;
|
|
|
890
967
|
- Cart field is `discountAmount`, NOT `discount`
|
|
891
968
|
- Cart has NO `total` field - use `getCartTotals()` or calculate
|
|
892
969
|
- Checkout DOES have a `total` field, but Cart does not
|
|
893
|
-
- `getCartTotals()` works with all carts
|
|
970
|
+
- `getCartTotals()` works with all carts; guests now use server-side session carts with full pricing fields.
|
|
894
971
|
|
|
895
972
|
### 15. SearchSuggestions - Products Have `price`, Not `basePrice`
|
|
896
973
|
|
|
@@ -979,9 +1056,9 @@ const totals = getCartTotals(cart); // { subtotal, discount, shipping, total }
|
|
|
979
1056
|
|
|
980
1057
|
### Detecting a Silent Session Cart Reset
|
|
981
1058
|
|
|
982
|
-
If a guest's stored session cart can no longer be resolved as-is
|
|
983
|
-
fails, or the cart was found but is no longer `ACTIVE
|
|
984
|
-
on it already completed)
|
|
1059
|
+
If a guest's stored session cart can no longer be resolved as-is (the fetch
|
|
1060
|
+
fails, or the cart was found but is no longer `ACTIVE`, e.g. a prior checkout
|
|
1061
|
+
on it already completed), `smartAddToCart()` / `smartGetCart()` /
|
|
985
1062
|
`smartUpdateCartItem()` transparently start a fresh empty cart so the call
|
|
986
1063
|
still succeeds. Pass `onCartReset` to the constructor to find out when this
|
|
987
1064
|
happens, so you can tell the shopper their cart expired instead of them just
|
|
@@ -997,7 +1074,7 @@ const client = new BrainerceClient({
|
|
|
997
1074
|
});
|
|
998
1075
|
```
|
|
999
1076
|
|
|
1000
|
-
### On Login
|
|
1077
|
+
### On Login: Merge Guest Cart
|
|
1001
1078
|
|
|
1002
1079
|
```typescript
|
|
1003
1080
|
// After setting customer token
|
|
@@ -1069,17 +1146,17 @@ console.log('Order created:', orderId);
|
|
|
1069
1146
|
|
|
1070
1147
|
Turn the shipping address's `line1` input into a typeahead instead of free
|
|
1071
1148
|
text. Suggestions come from Google Places; each resolved address is flagged
|
|
1072
|
-
`inZone` against the store's configured shipping zones
|
|
1073
|
-
warning banner
|
|
1149
|
+
`inZone` against the store's configured shipping zones, a soft signal for a
|
|
1150
|
+
warning banner and never a hard block.
|
|
1074
1151
|
|
|
1075
1152
|
Suggestions are limited to deliverable address types (street addresses, routes,
|
|
1076
1153
|
buildings, sub-premises). Businesses, stations and other establishments are
|
|
1077
|
-
never returned
|
|
1154
|
+
never returned, because a courier cannot deliver to one. A shopper who types only a
|
|
1078
1155
|
landmark name gets an empty list and has to type the street.
|
|
1079
1156
|
|
|
1080
1157
|
`inZone` resolves a zone's currency-region restriction the same way the checkout
|
|
1081
|
-
does
|
|
1082
|
-
default region
|
|
1158
|
+
does (destination country first, then the `regionId` you pass, then the store's
|
|
1159
|
+
default region), so a `true` here is not contradicted by the rates you fetch
|
|
1083
1160
|
afterwards.
|
|
1084
1161
|
|
|
1085
1162
|
```typescript
|
|
@@ -1174,7 +1251,7 @@ await client.smartUpdateCartItem('prod_123', 5);
|
|
|
1174
1251
|
await client.smartRemoveFromCart('prod_123');
|
|
1175
1252
|
```
|
|
1176
1253
|
|
|
1177
|
-
### After Login
|
|
1254
|
+
### After Login: Sync Cart
|
|
1178
1255
|
|
|
1179
1256
|
```typescript
|
|
1180
1257
|
client.setCustomerToken(token);
|
|
@@ -1182,14 +1259,14 @@ const mergedCart = await client.syncCartOnLogin();
|
|
|
1182
1259
|
// Guest session cart items are merged into the customer's server cart
|
|
1183
1260
|
```
|
|
1184
1261
|
|
|
1185
|
-
### After Checkout
|
|
1262
|
+
### After Checkout: Clear Cart
|
|
1186
1263
|
|
|
1187
1264
|
```typescript
|
|
1188
1265
|
client.onCheckoutComplete();
|
|
1189
1266
|
// Clears session cart reference so next visit starts fresh
|
|
1190
1267
|
```
|
|
1191
1268
|
|
|
1192
|
-
### After Logout
|
|
1269
|
+
### After Logout: Preserve Guest Cart
|
|
1193
1270
|
|
|
1194
1271
|
```typescript
|
|
1195
1272
|
client.clearCustomerToken();
|
|
@@ -1222,26 +1299,25 @@ export function getCartItemCount(): number {
|
|
|
1222
1299
|
return client.getSmartCartItemCount();
|
|
1223
1300
|
}
|
|
1224
1301
|
|
|
1225
|
-
// ----- Customer
|
|
1302
|
+
// ----- Customer Session -----
|
|
1303
|
+
// The auth token must NEVER live in localStorage: any XSS on the page reads it
|
|
1304
|
+
// and the attacker is that customer until it expires. Hand it to your own
|
|
1305
|
+
// server, which sets an HttpOnly cookie the browser cannot read back.
|
|
1226
1306
|
|
|
1227
|
-
export function
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
client.setCustomerToken(token);
|
|
1231
|
-
} else {
|
|
1232
|
-
localStorage.removeItem('customerToken');
|
|
1233
|
-
client.clearCustomerToken();
|
|
1234
|
-
}
|
|
1307
|
+
export async function startSession(token: string): Promise<void> {
|
|
1308
|
+
client.setCustomerToken(token);
|
|
1309
|
+
await fetch('/api/auth/session', { method: 'POST', body: JSON.stringify({ token }) });
|
|
1235
1310
|
}
|
|
1236
1311
|
|
|
1237
|
-
export function
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
return token;
|
|
1312
|
+
export async function endSession(): Promise<void> {
|
|
1313
|
+
client.clearCustomerToken();
|
|
1314
|
+
await fetch('/api/auth/logout', { method: 'POST' });
|
|
1241
1315
|
}
|
|
1242
1316
|
|
|
1243
|
-
|
|
1244
|
-
|
|
1317
|
+
// Session state comes from the server, not from a JS-readable flag.
|
|
1318
|
+
export async function getCurrentCustomer() {
|
|
1319
|
+
const res = await fetch('/api/auth/me');
|
|
1320
|
+
return res.ok ? res.json() : null;
|
|
1245
1321
|
}
|
|
1246
1322
|
```
|
|
1247
1323
|
|
|
@@ -1390,7 +1466,7 @@ interface CategoryNode {
|
|
|
1390
1466
|
|
|
1391
1467
|
#### Get Category by Slug (Category Page)
|
|
1392
1468
|
|
|
1393
|
-
Category (collection) pages are the highest-leverage organic-SEO surface
|
|
1469
|
+
Category (collection) pages are the highest-leverage organic-SEO surface, because they rank for broad "research intent" queries that individual product pages never do. `getCategoryBySlug` returns the landing-page payload; fetch the products themselves with `getProducts({ categories: [category.id] })`.
|
|
1394
1470
|
|
|
1395
1471
|
```typescript
|
|
1396
1472
|
// app/category/[slug]/page.tsx
|
|
@@ -1567,7 +1643,7 @@ function ProductFilters() {
|
|
|
1567
1643
|
|
|
1568
1644
|
**Key points for AI builders:**
|
|
1569
1645
|
|
|
1570
|
-
- `getCategories()` returns a **tree
|
|
1646
|
+
- `getCategories()` returns a **tree**; don't flatten it! Use `children` to build nested UI.
|
|
1571
1647
|
- Selecting a parent category automatically includes all descendants (backend handles this).
|
|
1572
1648
|
- Use `position: relative` on the chip wrapper and `position: absolute` on the dropdown for proper overlay positioning.
|
|
1573
1649
|
- Use `paddingInlineStart` (not `paddingLeft`) for RTL support.
|
|
@@ -1709,7 +1785,7 @@ function SearchInput() {
|
|
|
1709
1785
|
|
|
1710
1786
|
#### Product Type Definition
|
|
1711
1787
|
|
|
1712
|
-
> **The shipped `.d.ts` is the authority.** These are abridged for reading
|
|
1788
|
+
> **The shipped `.d.ts` is the authority.** These are abridged for reading, so
|
|
1713
1789
|
> import the real types (`import type { Product, ProductVariant } from 'brainerce'`)
|
|
1714
1790
|
> rather than retyping them. See the Critical Rule: _never write your own copies
|
|
1715
1791
|
> of SDK types._
|
|
@@ -1808,14 +1884,14 @@ interface InventoryInfo {
|
|
|
1808
1884
|
|
|
1809
1885
|
> **Variant prices are strings.** `variant.price` and `variant.salePrice` are
|
|
1810
1886
|
> `string | null`, exactly like `product.basePrice`. `variant.price > 100` compares
|
|
1811
|
-
> lexicographically and silently returns the wrong answer
|
|
1887
|
+
> lexicographically and silently returns the wrong answer, so always `parseFloat()`
|
|
1812
1888
|
> first, or use `getVariantPrice(variant)` / `formatVariantPrice(variant)`.
|
|
1813
1889
|
|
|
1814
1890
|
#### Product Metafields (Custom Fields)
|
|
1815
1891
|
|
|
1816
1892
|
Products can have custom fields (metafields) defined by the store owner, such as "Material", "Care Instructions", or "Warranty".
|
|
1817
1893
|
|
|
1818
|
-
**Important:** Each metafield has a `type` field. When rendering, you **must** check `field.type` and render accordingly
|
|
1894
|
+
**Important:** Each metafield has a `type` field. When rendering, you **must** check `field.type` and render accordingly, not just display `field.value` as text for all types.
|
|
1819
1895
|
|
|
1820
1896
|
| Type | Rendering |
|
|
1821
1897
|
| ----------------------------------------------------------- | ------------------------------------------------------- |
|
|
@@ -1887,7 +1963,7 @@ definitions.forEach((def) => {
|
|
|
1887
1963
|
**Faceted filtering with product counts.** Definitions the merchant marked
|
|
1888
1964
|
`filterable: true` (types `SELECT` / `MULTI_SELECT` / `BOOLEAN`) can be
|
|
1889
1965
|
rendered as storefront facets. `getMetafieldFilters()` returns each of them
|
|
1890
|
-
with per-value counts of distinct active products
|
|
1966
|
+
with per-value counts of distinct active products, so you can show
|
|
1891
1967
|
"Color: red (12) / blue (3)" without one `getProducts` call per value:
|
|
1892
1968
|
|
|
1893
1969
|
```typescript
|
|
@@ -1959,9 +2035,9 @@ await client.addToCart(cartId, {
|
|
|
1959
2035
|
});
|
|
1960
2036
|
```
|
|
1961
2037
|
|
|
1962
|
-
**Display customizations in cart/checkout
|
|
2038
|
+
**Display customizations in cart/checkout, with no extra API call needed:**
|
|
1963
2039
|
|
|
1964
|
-
`CartItem` and `CheckoutLineItem` both include a `customizations` object with resolved labels. Use it directly
|
|
2040
|
+
`CartItem` and `CheckoutLineItem` both include a `customizations` object with resolved labels. Use it directly; no need to call `getProduct()` per item.
|
|
1965
2041
|
|
|
1966
2042
|
```typescript
|
|
1967
2043
|
// Works for CartItem, CheckoutLineItem, and OrderItem — same shape
|
|
@@ -2002,7 +2078,7 @@ await client.addToCart(cartId, {
|
|
|
2002
2078
|
| GALLERY | `string[]` (URLs) | Multi-file upload |
|
|
2003
2079
|
| DIMENSION/WEIGHT | `{ value, unit }` | Value + unit inputs |
|
|
2004
2080
|
|
|
2005
|
-
**Assigning fields to a product is dashboard-only
|
|
2081
|
+
**Assigning fields to a product is dashboard-only; there is no SDK path to it.**
|
|
2006
2082
|
`client.setProductCustomizationFields()` and `client.getProductCustomizationFields()`
|
|
2007
2083
|
target `/api/v1/metafield-definitions/products/:productId/customization-fields`, which
|
|
2008
2084
|
the public API does not expose; both return **404**. Those routes exist only on the
|
|
@@ -2011,8 +2087,8 @@ behind Clerk auth. Choose which customer-input definitions apply to a product in
|
|
|
2011
2087
|
dashboard.
|
|
2012
2088
|
|
|
2013
2089
|
> **Two different things share the name `getProductCustomizationFields`.** The
|
|
2014
|
-
> **exported helper** used above
|
|
2015
|
-
>
|
|
2090
|
+
> **exported helper** used above, `import { getProductCustomizationFields } from 'brainerce'`,
|
|
2091
|
+
> is a pure function that reads the definitions off a product you already fetched. It
|
|
2016
2092
|
> works in every mode and is the one you want. The **client method** of the same name,
|
|
2017
2093
|
> which writes the assignment, is the one that 404s. Reading is fully covered without it:
|
|
2018
2094
|
> `product.customizationFields` is already on every product response.
|
|
@@ -2179,7 +2255,8 @@ Requires a customer token. Call it before rendering any review UI: it answers
|
|
|
2179
2255
|
all four cases in one request, sign in, not eligible, submit, edit.
|
|
2180
2256
|
|
|
2181
2257
|
```typescript
|
|
2182
|
-
const { eligible, reason, myReview, photos, myImages } =
|
|
2258
|
+
const { eligible, reason, myReview, photos, myImages } =
|
|
2259
|
+
await client.getMyProductReview('prod_123');
|
|
2183
2260
|
|
|
2184
2261
|
if (!eligible) {
|
|
2185
2262
|
// reason: 'no_eligible_order' | 'reviews_disabled' | 'product_not_found' | null
|
|
@@ -2372,7 +2449,7 @@ console.log(updated.couponCode); // "SAVE20"
|
|
|
2372
2449
|
await client.removeCoupon(cart.id);
|
|
2373
2450
|
```
|
|
2374
2451
|
|
|
2375
|
-
**On the checkout page** (checkout session already exists
|
|
2452
|
+
**On the checkout page** (checkout session already exists, preferred):
|
|
2376
2453
|
|
|
2377
2454
|
```typescript
|
|
2378
2455
|
// applyCheckoutCoupon applies to cart AND updates checkout totals atomically
|
|
@@ -2383,7 +2460,9 @@ console.log(checkout.total); // updated total
|
|
|
2383
2460
|
await client.removeCheckoutCoupon(checkoutId);
|
|
2384
2461
|
```
|
|
2385
2462
|
|
|
2386
|
-
> **Important:** if a checkout session already exists, always use `applyCheckoutCoupon(checkoutId, code)
|
|
2463
|
+
> **Important:** if a checkout session already exists, always use `applyCheckoutCoupon(checkoutId, code)`, not `applyCoupon(cartId, code)`. Applying to the cart after checkout is created does not update the checkout total, so payment will charge the original amount.
|
|
2464
|
+
|
|
2465
|
+
> **A gift card is not a coupon.** It has its own pair, `applyGiftCard(checkoutId, code)` / `removeGiftCard(checkoutId, tenderId)`, it never touches `discountAmount`, and it never moves `checkout.total` — a card is a means of payment, so what it reduces is `providerAmountDue`. See [Gift Cards](#gift-cards-a-tender-not-a-discount).
|
|
2387
2466
|
|
|
2388
2467
|
#### Cart Totals
|
|
2389
2468
|
|
|
@@ -2397,12 +2476,12 @@ const totals = getCartTotals(cart);
|
|
|
2397
2476
|
|
|
2398
2477
|
---
|
|
2399
2478
|
|
|
2400
|
-
### Guest Checkout (Submit Order)
|
|
2479
|
+
### Guest Checkout (Submit Order): no payment collected
|
|
2401
2480
|
|
|
2402
2481
|
> **⛔ Not for stores that take payment.** `submitGuestOrder()` posts the order
|
|
2403
2482
|
> straight to `POST /orders`. It never creates a payment intent, so the order is
|
|
2404
2483
|
> created **unpaid** and no card is ever charged. Use it only where checkout
|
|
2405
|
-
> collects no money
|
|
2484
|
+
> collects no money: cash on delivery, manual invoicing, or a sandbox store.
|
|
2406
2485
|
>
|
|
2407
2486
|
> For every other store use `startGuestCheckout()`, which creates a real checkout
|
|
2408
2487
|
> session from the session cart and hands you a `checkoutId` to run the payment
|
|
@@ -2552,7 +2631,7 @@ const cart = await client.createCart();
|
|
|
2552
2631
|
> **GA4 server-side conversions:** if you called `client.loadGoogleAnalytics('G-XXXXXXX')`
|
|
2553
2632
|
> once at app startup (see [Analytics](#analytics-optional-server-side-ga4-conversions)
|
|
2554
2633
|
> below), the resolved `client_id`/`session_id` are auto-attached to `createCart`,
|
|
2555
|
-
> `addToCart`, `setCheckoutCustomer`, and `setShippingAddress` automatically
|
|
2634
|
+
> `addToCart`, `setCheckoutCustomer`, and `setShippingAddress` automatically, with no
|
|
2556
2635
|
> other code changes needed. Pass `analyticsClientId`/`analyticsSessionId`
|
|
2557
2636
|
> explicitly on any of these calls to override.
|
|
2558
2637
|
|
|
@@ -2687,17 +2766,17 @@ const checkout = await client.createCheckout({
|
|
|
2687
2766
|
|
|
2688
2767
|
> The region is recorded on the checkout and used for payment-provider scoping.
|
|
2689
2768
|
> **FX-at-checkout:** when the region currency differs from the store base and its
|
|
2690
|
-
> provider can settle it (presentment-enabled
|
|
2769
|
+
> provider can settle it (presentment-enabled, Stripe today), the buyer is charged
|
|
2691
2770
|
> in the region currency and the checkout response carries a `presentment` overlay
|
|
2692
2771
|
> (`presentment.total` / `presentment.currency` = what's charged). Otherwise the
|
|
2693
2772
|
> checkout is charged in the store base currency.
|
|
2694
2773
|
|
|
2695
2774
|
#### Region display pricing (`getProducts({ regionId })`)
|
|
2696
2775
|
|
|
2697
|
-
All three product reads accept an optional `regionId
|
|
2776
|
+
All three product reads accept an optional `regionId`: `getProducts`,
|
|
2698
2777
|
`getProduct(id)`, **and** `getProductBySlug(slug)` (the PDP path). When set and the
|
|
2699
2778
|
region's currency differs from the store currency, each product/variant gains
|
|
2700
|
-
additive FX-display fields
|
|
2779
|
+
additive FX-display fields: `displayPrice`, `displaySalePrice`, and
|
|
2701
2780
|
`displayCurrency` (plus `displayPriceMin` / `displayPriceMax` on storefront-mode
|
|
2702
2781
|
list reads). Your `basePrice` / `salePrice` stay in the store currency; the
|
|
2703
2782
|
display fields appear **only** when an FX rate applies, so an omitted/invalid
|
|
@@ -2716,11 +2795,11 @@ await client.getProductBySlug('blue-shirt', { regionId: 'region_eu' });
|
|
|
2716
2795
|
```
|
|
2717
2796
|
|
|
2718
2797
|
> **Display-only, and unlike checkout.** `regionId` on a _product read_ never
|
|
2719
|
-
> affects what is charged
|
|
2798
|
+
> affects what is charged; it only changes what you render. `regionId` on
|
|
2720
2799
|
> `createCheckout()` is different: it CAN charge the region currency (see
|
|
2721
2800
|
> FX-at-checkout above). Do not carry the "display-only" assumption from here into
|
|
2722
2801
|
> the checkout step. Works in
|
|
2723
|
-
> **all three modes
|
|
2802
|
+
> **all three modes**: vibe-coded (`vc_*`/`salesChannelId`), storefront (`storeId`),
|
|
2724
2803
|
> and admin (`apiKey`). The response gains `displayPrice` whenever a daily FX rate
|
|
2725
2804
|
> exists for the store/region currency pair in **either** direction (the overlay
|
|
2726
2805
|
> inverts the stored rate when needed). See [Regions](/docs/concepts/regions).
|
|
@@ -2801,13 +2880,13 @@ console.log(rates); // ShippingRate[]
|
|
|
2801
2880
|
> re-resolves it to the address's exact coordinates, which is how stores that
|
|
2802
2881
|
> draw their delivery areas on a map ("polygon" zones) decide whether they
|
|
2803
2882
|
> cover the shopper. Without it the server falls back to geocoding the typed
|
|
2804
|
-
> address text, which is materially less precise
|
|
2883
|
+
> address text, which is materially less precise: a same-named street in a
|
|
2805
2884
|
> neighbouring city can outrank the right one, quoting the shopper another
|
|
2806
2885
|
> area's rate or no delivery at all. **Clear `placeId` if the shopper edits any
|
|
2807
|
-
> address field after picking
|
|
2886
|
+
> address field after picking**, because its coordinates describe the suggestion, not
|
|
2808
2887
|
> the edited text. There is deliberately no `lat`/`lng` field: zone matching
|
|
2809
2888
|
> decides which rate is charged, so coordinates are never accepted from the
|
|
2810
|
-
> client
|
|
2889
|
+
> client. The server resolves them from `placeId` itself.
|
|
2811
2890
|
>
|
|
2812
2891
|
> The endpoint rejects **any** unknown property with a `400` ("property lat
|
|
2813
2892
|
> should not exist"), which blocks checkout entirely rather than degrading. So
|
|
@@ -2822,7 +2901,7 @@ console.log(rates); // ShippingRate[]
|
|
|
2822
2901
|
> same area, put several rates on one zone rather than several zones.
|
|
2823
2902
|
|
|
2824
2903
|
> **Live carrier rates:** render `rate.speedTier` (`'cheapest' | 'balanced' | 'fastest'`)
|
|
2825
|
-
> and `rate.estimatedDays`, not `rate.name
|
|
2904
|
+
> and `rate.estimatedDays`, not `rate.name`, because `name` is the carrier's own service
|
|
2826
2905
|
> identifier and means nothing to a shopper. Rates arrive already narrowed to at most
|
|
2827
2906
|
> three. Manual zone rates carry no `speedTier`; show their `name` as the merchant wrote
|
|
2828
2907
|
> it. Full snippet under [Checkout Type Definition](#checkout-type-definition).
|
|
@@ -2830,7 +2909,7 @@ console.log(rates); // ShippingRate[]
|
|
|
2830
2909
|
> **Order notes:** every checkout page should render an optional **"Order
|
|
2831
2910
|
> notes"** textarea by default. Send its value via `notes` on either
|
|
2832
2911
|
> `setCheckoutCustomer` or `setShippingAddress` (whichever call your flow
|
|
2833
|
-
> makes last)
|
|
2912
|
+
> makes last). Max 2000 chars, and an empty string clears a previously-set note.
|
|
2834
2913
|
> The note is copied onto the order at completion (merchant sees it in the
|
|
2835
2914
|
> dashboard, it's included in the confirmation email) and echoed back
|
|
2836
2915
|
> read-only as `Order.notes` on buyer order responses.
|
|
@@ -2890,10 +2969,10 @@ const updatedCheckout = await client.setCheckoutCustomFields(checkoutId, {
|
|
|
2890
2969
|
|
|
2891
2970
|
A `DATE`/`DATETIME` field's `dateAvailability` (blocked weekdays, blocked specific
|
|
2892
2971
|
dates, min/max date range, the relative bounds `leadTimeMinutes` / `cutoffTime` /
|
|
2893
|
-
`maxDaysAhead`,
|
|
2972
|
+
`maxDaysAhead`, plus (for `DATETIME`) business hours and time
|
|
2894
2973
|
slots) is a merchant-configured restriction on which values the customer may
|
|
2895
2974
|
pick. Use `computeAvailableSlots()` / `getBusinessHoursForDate()` /
|
|
2896
|
-
`isDateValueAllowed()` to drive your own date-picker/slot-picker UI
|
|
2975
|
+
`isDateValueAllowed()` to drive your own date-picker/slot-picker UI. The SDK
|
|
2897
2976
|
ships no calendar component, only the math (evaluated in the **store's**
|
|
2898
2977
|
timezone, never the browser's):
|
|
2899
2978
|
|
|
@@ -2938,10 +3017,10 @@ if (slots.length) {
|
|
|
2938
3017
|
```
|
|
2939
3018
|
|
|
2940
3019
|
**Submitting the value.** `DATE` is `"YYYY-MM-DD"`. `DATETIME` is one ISO-8601
|
|
2941
|
-
value
|
|
3020
|
+
value: `"2026-08-15T09:30:00+03:00"`, or `"2026-08-15T09:30"` to mean the
|
|
2942
3021
|
store's own timezone (the safest choice: a buyer travelling abroad would
|
|
2943
3022
|
otherwise book their local hour). Fractional seconds are optional and may carry
|
|
2944
|
-
1
|
|
3023
|
+
1 to 9 digits, so `Instant.toString()` / `datetime.isoformat()` output from a
|
|
2945
3024
|
non-JS backend is accepted as-is. **Never build it by concatenating a slot
|
|
2946
3025
|
label**: `` `${date}T13:00-14:00` `` is rejected with HTTP 400 because
|
|
2947
3026
|
`-14:00` parses as a UTC offset, not a time range.
|
|
@@ -2964,7 +3043,7 @@ concrete dates they currently mean, which is what to show a shopper who asked
|
|
|
2964
3043
|
for something too soon.
|
|
2965
3044
|
|
|
2966
3045
|
The backend independently re-validates every submitted value against the same
|
|
2967
|
-
constraints at write time
|
|
3046
|
+
constraints at write time. This is a client-side UX aid, not the source of
|
|
2968
3047
|
enforcement.
|
|
2969
3048
|
|
|
2970
3049
|
**Pricing types:**
|
|
@@ -2975,6 +3054,46 @@ enforcement.
|
|
|
2975
3054
|
- `per_option` → each SELECT option has its own price
|
|
2976
3055
|
- `conditional` → surcharge when NUMBER value meets condition (gt, gte, lt, lte, eq)
|
|
2977
3056
|
|
|
3057
|
+
#### Gift Cards (a tender, not a discount)
|
|
3058
|
+
|
|
3059
|
+
All three modes. **Reading the switch is channel-only**, though: `getStoreCapabilities().features.hasGiftCards` exists on a `salesChannelId` client and that call throws in the other two modes. `getStoreInfo()` carries no gift-card flag, so a `storeId` or admin integration has no capability probe — build the field unconditionally there. Nothing breaks if the store has no cards: an unusable code is simply refused.
|
|
3060
|
+
|
|
3061
|
+
```typescript
|
|
3062
|
+
// Optional pre-check before applying. Rate limited (5/min).
|
|
3063
|
+
const { balance, currency, usable } = await client.checkGiftCardBalance(code);
|
|
3064
|
+
// usable === false covers unknown, disabled AND expired — you cannot tell which.
|
|
3065
|
+
|
|
3066
|
+
// Apply. The order total does NOT change; tax stays on the full value.
|
|
3067
|
+
const { tenderId, amountApplied, providerAmountDue } = await client.applyGiftCard(checkoutId, code);
|
|
3068
|
+
// amountApplied: '54.50' — only what the order still owed; the rest stays on the card
|
|
3069
|
+
// providerAmountDue: '150.50' — what the provider will be charged
|
|
3070
|
+
|
|
3071
|
+
// Render from the checkout, not from the response above — the hold is server-side.
|
|
3072
|
+
const checkout = await client.getCheckout(checkoutId);
|
|
3073
|
+
checkout.total; // unchanged by the card
|
|
3074
|
+
checkout.tenders; // [{ tenderId, amountApplied }], oldest first
|
|
3075
|
+
checkout.providerAmountDue; // total − every applied card
|
|
3076
|
+
|
|
3077
|
+
// Remove by tenderId, never by code. Held value goes straight back to the card.
|
|
3078
|
+
await client.removeGiftCard(checkoutId, tenderId);
|
|
3079
|
+
```
|
|
3080
|
+
|
|
3081
|
+
Summary layout — the card belongs **below** the total, never in the discount block:
|
|
3082
|
+
|
|
3083
|
+
```
|
|
3084
|
+
Subtotal ₪160.00
|
|
3085
|
+
Discount −₪0.00
|
|
3086
|
+
Shipping ₪20.00
|
|
3087
|
+
Tax ₪25.00
|
|
3088
|
+
Total ₪205.00 ← unchanged; this is what tax was calculated on
|
|
3089
|
+
Gift card −₪54.50 ← its own line, below the total
|
|
3090
|
+
Amount due ₪150.50 ← checkout.providerAmountDue, what the provider is charged
|
|
3091
|
+
```
|
|
3092
|
+
|
|
3093
|
+
**Every refusal is the same 400 with the same message.** Unknown, expired, spent, disabled and wrong-currency are indistinguishable on purpose — a response that told them apart is an oracle for walking the code space. Show one message and let the shopper re-type the code. A card also pays only in its own currency; there is no conversion.
|
|
3094
|
+
|
|
3095
|
+
**Apply and remove before `createPaymentIntent`.** Once the checkout is `PAYMENT_PENDING` / `PAYMENT_PROCESSING`, both calls fail with `CHECKOUT_LOCKED`. The intent's `amount` already has live cards netted off server-side, so never subtract anything yourself. If `providerAmountDue` is `'0.00'` the cards cover the order: skip the provider, call `completeCheckout(checkoutId)` (it returns `{ orderId }`), then `handlePaymentSuccess(checkoutId)` to clear the cart.
|
|
3096
|
+
|
|
2978
3097
|
#### Checkout Type Definition
|
|
2979
3098
|
|
|
2980
3099
|
```typescript
|
|
@@ -2991,14 +3110,39 @@ interface Checkout {
|
|
|
2991
3110
|
shippingAmount: string;
|
|
2992
3111
|
taxAmount: string; // "0" in inclusive (VAT) mode — see taxBreakdown.totalTax
|
|
2993
3112
|
taxBreakdown?: TaxBreakdown | null; // { totalTax, pricesIncludeTax, breakdown[] }
|
|
3113
|
+
// breakdown[] is ONE ROW PER TAX and is often more than one row (Canada charges
|
|
3114
|
+
// GST + PST/QST). Loop it; never read breakdown[0].
|
|
2994
3115
|
total: string;
|
|
2995
3116
|
couponCode?: string | null;
|
|
3117
|
+
// Gift cards held against this checkout, oldest first. Read these to re-render
|
|
3118
|
+
// applied cards after a reload — the hold lives on the server, not in your state.
|
|
3119
|
+
tenders?: Array<{ tenderId: string; amountApplied: string }>;
|
|
3120
|
+
// What the payment provider will be charged: `total` minus every gift card.
|
|
3121
|
+
// `total` above is deliberately NOT reduced — a gift card is a means of payment,
|
|
3122
|
+
// not a discount, and tax stays calculated on the full order value.
|
|
3123
|
+
providerAmountDue?: string;
|
|
2996
3124
|
notes?: string | null; // Order note from setCheckoutCustomer/setShippingAddress
|
|
2997
3125
|
items: CheckoutLineItem[];
|
|
2998
3126
|
itemCount: number;
|
|
2999
3127
|
availableShippingRates?: ShippingRate[];
|
|
3000
3128
|
}
|
|
3001
3129
|
|
|
3130
|
+
// Returned by applyGiftCard. Note what is NOT here: the code, the card id, or
|
|
3131
|
+
// anything about who owns it.
|
|
3132
|
+
interface CheckoutTender {
|
|
3133
|
+
tenderId: string; // pass to removeGiftCard; a checkout can carry several cards
|
|
3134
|
+
amountApplied: string; // capped at what the order still owes
|
|
3135
|
+
providerAmountDue: string; // what the provider will be charged after this card
|
|
3136
|
+
}
|
|
3137
|
+
|
|
3138
|
+
// Returned by checkGiftCardBalance. Identical for an unknown code, a disabled
|
|
3139
|
+
// card and an expired one — by design.
|
|
3140
|
+
interface GiftCardBalance {
|
|
3141
|
+
balance: string; // spendable balance, or '0.00' when the card cannot be used
|
|
3142
|
+
currency: string;
|
|
3143
|
+
usable: boolean;
|
|
3144
|
+
}
|
|
3145
|
+
|
|
3002
3146
|
type CheckoutStatus = 'PENDING' | 'SHIPPING_SET' | 'PAYMENT_PENDING' | 'COMPLETED' | 'FAILED';
|
|
3003
3147
|
|
|
3004
3148
|
interface ShippingRate {
|
|
@@ -3016,8 +3160,8 @@ interface ShippingRate {
|
|
|
3016
3160
|
```
|
|
3017
3161
|
|
|
3018
3162
|
**Render `speedTier` and `estimatedDays`, not `name`, for live carrier rates.**
|
|
3019
|
-
`name` carries the carrier's own service identifier
|
|
3020
|
-
`USAExportPBA USAExportStandard`
|
|
3163
|
+
`name` carries the carrier's own service identifier (`USPS PriorityMailInternational`,
|
|
3164
|
+
`USAExportPBA USAExportStandard`), which answers a question no shopper asked. They are
|
|
3021
3165
|
choosing between _how fast_ and _how much_. Label the tiers in your own words and locale:
|
|
3022
3166
|
|
|
3023
3167
|
```typescript
|
|
@@ -3167,12 +3311,12 @@ const checkoutId = checkout.id;
|
|
|
3167
3311
|
Use this method to get ALL enabled payment providers and build dynamic UI.
|
|
3168
3312
|
|
|
3169
3313
|
**Primary vs. additive methods (Shopify-parity).** Each provider carries a
|
|
3170
|
-
`methodType`. A `CREDIT_CARD` provider is the _primary_ card processor
|
|
3314
|
+
`methodType`. A `CREDIT_CARD` provider is the _primary_ card processor, the
|
|
3171
3315
|
single method that settles the order (`defaultProvider`, `isAdditive: false`,
|
|
3172
3316
|
`presentation: 'card_form'`). Everything else is _additive_ (`isAdditive: true`),
|
|
3173
3317
|
e.g. PayPal is a `'WALLET'` with `presentation: 'express_button'`. Render additive
|
|
3174
|
-
methods as accelerated-checkout **express buttons above the card form
|
|
3175
|
-
_alongside_ the primary
|
|
3318
|
+
methods as accelerated-checkout **express buttons above the card form**, where they sit
|
|
3319
|
+
_alongside_ the primary and never replace it. (Exception: a wallet-only store has no
|
|
3176
3320
|
card processor, so its wallet becomes the `defaultProvider` and stands alone.)
|
|
3177
3321
|
|
|
3178
3322
|
```typescript
|
|
@@ -3215,9 +3359,11 @@ const { hasPayments, providers, defaultProvider } = await client.getPaymentProvi
|
|
|
3215
3359
|
const expressMethods = providers.filter(p => p.isAdditive); // e.g. PayPal
|
|
3216
3360
|
const primary = defaultProvider && !defaultProvider.isAdditive ? defaultProvider : undefined;
|
|
3217
3361
|
|
|
3218
|
-
// Build dynamic UI based on available providers
|
|
3362
|
+
// Build dynamic UI based on available providers.
|
|
3363
|
+
// hasPayments:false is NORMAL for a new store, not an error: build the whole checkout
|
|
3364
|
+
// anyway and scope this notice to the payment step. Never ship a disabled Pay button.
|
|
3219
3365
|
if (!hasPayments) {
|
|
3220
|
-
return <div>Payment not
|
|
3366
|
+
return <div>Payment is not set up for this store yet</div>;
|
|
3221
3367
|
}
|
|
3222
3368
|
|
|
3223
3369
|
const stripeProvider = providers.find(p => p.provider === 'stripe');
|
|
@@ -3235,7 +3381,7 @@ if (paypalProvider) {
|
|
|
3235
3381
|
}
|
|
3236
3382
|
```
|
|
3237
3383
|
|
|
3238
|
-
#### Get Payment Configuration (Single Provider)
|
|
3384
|
+
#### Get Payment Configuration (Single Provider): DEPRECATED
|
|
3239
3385
|
|
|
3240
3386
|
> **`getPaymentConfig()` is `@deprecated`.** It only ever describes one provider, so
|
|
3241
3387
|
> a store with an additive express method (PayPal, a wallet) renders wrong. Use
|
|
@@ -3284,13 +3430,13 @@ both are omitted from most copy-paste snippets. `clientSdk.renderType` is one of
|
|
|
3284
3430
|
|
|
3285
3431
|
| `renderType` | What `clientSecret` holds | What to do |
|
|
3286
3432
|
| -------------- | ------------------------- | ------------------------------------------------------------------------------------------------ |
|
|
3287
|
-
| `'sandbox'` | (unused) | No payment UI
|
|
3433
|
+
| `'sandbox'` | (unused) | No payment UI; complete the checkout directly |
|
|
3288
3434
|
| `'sdk-widget'` | Client secret / auth code | Load `clientSdk.scriptUrl`, init with `clientSdk.initConfig`, mount into `clientSdk.containerId` |
|
|
3289
3435
|
| `'iframe'` | **A URL** | Load it in an iframe (inline if its path contains `/embed/`, else in a modal) |
|
|
3290
3436
|
| `'redirect'` | **A URL** | Navigate the top-level window to it; on return call `confirmSdkPayment()` |
|
|
3291
3437
|
|
|
3292
|
-
Branch on `clientSdk?.renderType`. **Never** branch on "does `clientSdk` exist"
|
|
3293
|
-
every provider returns one, sandbox included
|
|
3438
|
+
Branch on `clientSdk?.renderType`. **Never** branch on "does `clientSdk` exist":
|
|
3439
|
+
every provider returns one, sandbox included. And never hard-code by provider name.
|
|
3294
3440
|
Only `provider === 'stripe'` has a `clientSdk.initConfig.publishableKey`.
|
|
3295
3441
|
|
|
3296
3442
|
#### Confirm an SDK / redirect payment
|
|
@@ -3306,14 +3452,14 @@ await client.confirmSdkPayment(checkoutId, { transactionId: 'txn_123' });
|
|
|
3306
3452
|
|
|
3307
3453
|
Call it in two places:
|
|
3308
3454
|
|
|
3309
|
-
- **In an in-page SDK's success callback** (`renderType: 'sdk-widget'`)
|
|
3455
|
+
- **In an in-page SDK's success callback** (`renderType: 'sdk-widget'`), where it tells
|
|
3310
3456
|
the backend the payment succeeded, which triggers order creation.
|
|
3311
|
-
- **On the return page from a `renderType: 'redirect'` provider
|
|
3457
|
+
- **On the return page from a `renderType: 'redirect'` provider**, because redirect
|
|
3312
3458
|
providers don't capture until the server confirms, so this is what makes the
|
|
3313
3459
|
backend verify with the provider and capture.
|
|
3314
3460
|
|
|
3315
3461
|
It is **idempotent** (safe if a webhook already captured) and safe to skip on
|
|
3316
|
-
failure
|
|
3462
|
+
failure, since `getPaymentStatus()` / `waitForOrder()` re-verify server-side. Wrap it in
|
|
3317
3463
|
`try/catch` and carry on:
|
|
3318
3464
|
|
|
3319
3465
|
```typescript
|
|
@@ -3325,13 +3471,13 @@ try {
|
|
|
3325
3471
|
const result = await client.waitForOrder(checkoutId);
|
|
3326
3472
|
```
|
|
3327
3473
|
|
|
3328
|
-
Do **not** call it on your `cancelUrl
|
|
3474
|
+
Do **not** call it on your `cancelUrl`. The buyer abandoned; just let them retry.
|
|
3329
3475
|
|
|
3330
3476
|
`confirmGrowPayment()` is a deprecated wrapper around this method; call
|
|
3331
3477
|
`confirmSdkPayment()` directly.
|
|
3332
3478
|
|
|
3333
3479
|
> `confirmSdkPayment()`, `getPaymentStatus()`, `createPaymentIntent()`,
|
|
3334
|
-
> `getPaymentProviders()` and `waitForOrder()` are **sales-channel mode only
|
|
3480
|
+
> `getPaymentProviders()` and `waitForOrder()` are **sales-channel mode only**;
|
|
3335
3481
|
> they throw `BrainerceError` 400 on a `storeId` or `apiKey` client.
|
|
3336
3482
|
|
|
3337
3483
|
**Routing to a specific provider (`providerId`).** With `getPaymentProviders()` you
|
|
@@ -3604,11 +3750,11 @@ function PaymentForm({ checkoutId }: { checkoutId: string }) {
|
|
|
3604
3750
|
|
|
3605
3751
|
#### Complete Order After Payment: `completeGuestCheckout()` (legacy untracked flow only)
|
|
3606
3752
|
|
|
3607
|
-
> **Context:** This section describes the **legacy untracked guest checkout** flow where the client must explicitly create the order. In the modern tracked flow (`startGuestCheckout()` → webhook creates the order), you use `handlePaymentSuccess()` + `waitForOrder()` instead (see [Business Flows
|
|
3753
|
+
> **Context:** This section describes the **legacy untracked guest checkout** flow where the client must explicitly create the order. In the modern tracked flow (`startGuestCheckout()` → webhook creates the order), you use `handlePaymentSuccess()` + `waitForOrder()` instead (see [Business Flows: Checkout](#checkout-flow) above).
|
|
3608
3754
|
|
|
3609
3755
|
**CRITICAL (untracked flow only):** After payment succeeds, you MUST call `completeGuestCheckout()` to create the order on the server.
|
|
3610
3756
|
|
|
3611
|
-
> **WARNING (untracked flow only):** Do NOT use `handlePaymentSuccess()` here
|
|
3757
|
+
> **WARNING (untracked flow only):** Do NOT use `handlePaymentSuccess()` here; it only clears cart state locally and does NOT create the order on the server. The tracked flow uses `handlePaymentSuccess` + `waitForOrder`; the untracked flow uses `completeGuestCheckout` directly.
|
|
3612
3758
|
|
|
3613
3759
|
```typescript
|
|
3614
3760
|
// On your /checkout/success page:
|
|
@@ -3696,7 +3842,7 @@ if (intent.clientSdk?.renderType === 'sandbox') {
|
|
|
3696
3842
|
|
|
3697
3843
|
#### Register Customer
|
|
3698
3844
|
|
|
3699
|
-
> **Password policy
|
|
3845
|
+
> **Password policy, enforced on `registerCustomer()` AND `resetPassword()`.**
|
|
3700
3846
|
> At least **8 characters**, with at least one **lowercase** letter, one
|
|
3701
3847
|
> **uppercase** letter, one **digit**, and one **special character**
|
|
3702
3848
|
> (`/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^a-zA-Z0-9]).{8,}$/`). A password that
|
|
@@ -3705,7 +3851,7 @@ if (intent.clientSdk?.renderType === 'sandbox') {
|
|
|
3705
3851
|
>
|
|
3706
3852
|
> `securepassword123` fails (no uppercase, no special). `Password123` fails (no
|
|
3707
3853
|
> special). `SecurePass123!` passes. Mirror the full rule in your own client-side
|
|
3708
|
-
> validation and in the field's helper text
|
|
3854
|
+
> validation and in the field's helper text. A form that only says "min 8
|
|
3709
3855
|
> characters" produces a 400 the shopper cannot explain, and render the server's
|
|
3710
3856
|
> message verbatim when one comes back.
|
|
3711
3857
|
|
|
@@ -3754,7 +3900,7 @@ localStorage.removeItem('returnUrl');
|
|
|
3754
3900
|
window.location.href = returnUrl;
|
|
3755
3901
|
```
|
|
3756
3902
|
|
|
3757
|
-
> **`setCustomerToken()` is a plain field setter
|
|
3903
|
+
> **`setCustomerToken()` is a plain field setter; it does not touch the cart.**
|
|
3758
3904
|
> Always follow it with `await client.syncCartOnLogin()`. Skip it and the
|
|
3759
3905
|
> shopper's guest cart is never attached to their account, which quietly breaks
|
|
3760
3906
|
> every identity-keyed feature: first-order discounts, per-customer coupon caps,
|
|
@@ -3893,8 +4039,8 @@ await client.reportSocialShare('instagram');
|
|
|
3893
4039
|
#### Referrals & Birthday Gifts
|
|
3894
4040
|
|
|
3895
4041
|
When the store enables referrals, `getLoyaltyStatus()` also returns the
|
|
3896
|
-
member's share code (`referralCode`) and
|
|
3897
|
-
referral link
|
|
4042
|
+
member's share code (`referralCode`) and, for customers who signed up via a
|
|
4043
|
+
referral link, their still-unused welcome coupon (`referralWelcomeCoupon`).
|
|
3898
4044
|
|
|
3899
4045
|
```typescript
|
|
3900
4046
|
// 1. The referrer shares a link you build around their code.
|
|
@@ -3928,7 +4074,7 @@ signup.
|
|
|
3928
4074
|
|
|
3929
4075
|
#### Paid Loyalty Membership
|
|
3930
4076
|
|
|
3931
|
-
Stores can offer a paid "premium membership" inside the loyalty program
|
|
4077
|
+
Stores can offer a paid "premium membership" inside the loyalty program: a
|
|
3932
4078
|
recurring charge (default every 30 days) that grants a points multiplier and
|
|
3933
4079
|
other perks. Storefront or vibe-coded mode, requires `customerToken`. The
|
|
3934
4080
|
customer must first have a saved card (vault one by checking out with
|
|
@@ -3966,7 +4112,7 @@ const cancelled = await client.cancelMembership();
|
|
|
3966
4112
|
|
|
3967
4113
|
#### Embeddable Loyalty Widget
|
|
3968
4114
|
|
|
3969
|
-
Drop the loyalty program into ANY website
|
|
4115
|
+
Drop the loyalty program into ANY website, not just your SDK-connected
|
|
3970
4116
|
storefront. Mint a short-lived widget session (never exposes the real
|
|
3971
4117
|
`customerToken` to the embedding page) and point an `<iframe>` at it. The
|
|
3972
4118
|
merchant enables this per-domain in the dashboard (Loyalty → Settings →
|
|
@@ -3980,7 +4126,7 @@ const { embedUrl } = await client.getLoyaltyWidgetSession();
|
|
|
3980
4126
|
// <iframe src={embedUrl} width="360" height="420" style={{ border: 0 }} />
|
|
3981
4127
|
```
|
|
3982
4128
|
|
|
3983
|
-
`embedUrl` expires in ~15 minutes
|
|
4129
|
+
`embedUrl` expires in ~15 minutes, so re-call `getLoyaltyWidgetSession()` before
|
|
3984
4130
|
that (e.g. on page load) rather than caching it long-term. Storefront or
|
|
3985
4131
|
vibe-coded mode, requires `customerToken`.
|
|
3986
4132
|
|
|
@@ -4187,7 +4333,7 @@ window.location.href = authorizationUrl;
|
|
|
4187
4333
|
|
|
4188
4334
|
The backend handles the OAuth code exchange automatically and redirects to your callback page with URL params. You do **not** need to call `handleOAuthCallback()`.
|
|
4189
4335
|
|
|
4190
|
-
Both outcomes land here
|
|
4336
|
+
Both outcomes land here: success **and** failure. Read `auth_code` and exchange it for the JWT; never read the token from the URL.
|
|
4191
4337
|
|
|
4192
4338
|
```typescript
|
|
4193
4339
|
// app/auth/callback/page.tsx
|
|
@@ -4509,11 +4655,131 @@ console.log(store.language); // 'en', 'he', etc.
|
|
|
4509
4655
|
|
|
4510
4656
|
---
|
|
4511
4657
|
|
|
4658
|
+
### Store Capabilities
|
|
4659
|
+
|
|
4660
|
+
What the merchant actually configured on this sales channel. **Build against this instead of hardcoding.** Vibe-coded (`salesChannelId`) mode only: the payload belongs to one sales channel, so `storeId` mode has no channel to read it from and an admin `apiKey` addresses the store rather than any single channel. Calling it in another mode throws a `BrainerceError` with status 400.
|
|
4661
|
+
|
|
4662
|
+
Fetch it **once** at app start and share it (React context, a store, a module-level cache). It is per channel, not per product, so a fetch per page is repeated work.
|
|
4663
|
+
|
|
4664
|
+
```typescript
|
|
4665
|
+
const caps = await client.getStoreCapabilities();
|
|
4666
|
+
|
|
4667
|
+
// --- store: identity + language ---
|
|
4668
|
+
caps.store.name; // Parent store name
|
|
4669
|
+
caps.store.channelName; // This channel's display name
|
|
4670
|
+
caps.store.currency; // 'USD', 'ILS', ...
|
|
4671
|
+
caps.store.language; // 'en', 'he', ...
|
|
4672
|
+
caps.store.i18n; // present ONLY when multi-language is enabled
|
|
4673
|
+
|
|
4674
|
+
// --- connection: this channel's settings ---
|
|
4675
|
+
caps.connection.lowStockWarning; // false = show no low-stock treatment at all
|
|
4676
|
+
caps.connection.lowStockThreshold; // units at or below which stock is "low"
|
|
4677
|
+
caps.connection.stockAlertsEnabled; // false = do not render "email me when back"
|
|
4678
|
+
caps.connection.requireBirthday; // true = birthday required on the signup form
|
|
4679
|
+
caps.connection.requireEmailVerification;
|
|
4680
|
+
caps.connection.reservationStrategy; // 'ON_CART' | 'ON_CHECKOUT' | 'ON_PAYMENT'
|
|
4681
|
+
caps.connection.reservationTimeout; // minutes before a reservation expires
|
|
4682
|
+
caps.connection.ordersWriteEnabled;
|
|
4683
|
+
caps.connection.guestCheckoutTracking;
|
|
4684
|
+
caps.connection.sandboxPaymentsEnabled;
|
|
4685
|
+
caps.connection.allowedScopes;
|
|
4686
|
+
|
|
4687
|
+
// --- features: which optional features exist ---
|
|
4688
|
+
caps.features.paymentProviders; // [{ name, provider }] — empty means checkout cannot take money yet
|
|
4689
|
+
caps.features.oauthProviders; // [{ provider, isEnabled }]
|
|
4690
|
+
caps.features.hasShippingZones;
|
|
4691
|
+
caps.features.hasDiscountRules;
|
|
4692
|
+
caps.features.hasCoupons;
|
|
4693
|
+
caps.features.hasDownloadableProducts;
|
|
4694
|
+
caps.features.hasCheckoutCustomFields;
|
|
4695
|
+
caps.features.hasGiftCards; // a per-store switch, not a count — see below
|
|
4696
|
+
caps.features.hasContent;
|
|
4697
|
+
caps.features.hasLoyaltyProgram;
|
|
4698
|
+
caps.features.hasReferralProgram;
|
|
4699
|
+
caps.features.hasBirthdayRewards;
|
|
4700
|
+
caps.features.hasBadges;
|
|
4701
|
+
caps.features.hasPaidMembership;
|
|
4702
|
+
caps.features.hasAiRewardRecommendation;
|
|
4703
|
+
```
|
|
4704
|
+
|
|
4705
|
+
**`hasGiftCards` is a switch, not a count.** A store that has issued no cards yet still reports `true` the moment the merchant enables the feature, so build the redemption field on `true` and do not wait for a card to exist. Like every other conditional feature here it auto-hides, and the day a card is issued the storefront already honours it.
|
|
4706
|
+
|
|
4707
|
+
**Honour the switch before the number.** `lowStockWarning` is a separate flag from `lowStockThreshold`. When it is off the merchant deliberately turned the urgency messaging off, and a storefront that reads only the threshold keeps showing "Only 3 left" anyway:
|
|
4708
|
+
|
|
4709
|
+
```typescript
|
|
4710
|
+
const lowStockThreshold = caps.connection.lowStockWarning ? caps.connection.lowStockThreshold : 0; // 0 disables the low-stock state in getStockStatus()
|
|
4711
|
+
|
|
4712
|
+
const stockText = getStockStatus(product.inventory, { lowStockThreshold });
|
|
4713
|
+
```
|
|
4714
|
+
|
|
4715
|
+
**Never let this call break the page.** A brand-new store is empty and the fetch can fail. Catch it and fall back to your own defaults rather than blocking the render:
|
|
4716
|
+
|
|
4717
|
+
```typescript
|
|
4718
|
+
const caps = await client.getStoreCapabilities().catch(() => null);
|
|
4719
|
+
const lowStockThreshold = caps
|
|
4720
|
+
? caps.connection.lowStockWarning
|
|
4721
|
+
? caps.connection.lowStockThreshold
|
|
4722
|
+
: 0
|
|
4723
|
+
: 5; // platform default
|
|
4724
|
+
```
|
|
4725
|
+
|
|
4726
|
+
The full response type is exported as `StoreCapabilities`:
|
|
4727
|
+
|
|
4728
|
+
```typescript
|
|
4729
|
+
import type { StoreCapabilities } from 'brainerce';
|
|
4730
|
+
```
|
|
4731
|
+
|
|
4732
|
+
---
|
|
4733
|
+
|
|
4734
|
+
### Donations (start & read back)
|
|
4735
|
+
|
|
4736
|
+
Vibe-coded (`salesChannelId`) or storefront (`storeId`) mode. An admin `apiKey` client throws: a donation is a donor-facing act, and the key is for managing gifts after the fact.
|
|
4737
|
+
|
|
4738
|
+
```typescript
|
|
4739
|
+
// Gate first — this page does NOT auto-hide.
|
|
4740
|
+
const store = await brainerce.getStoreInfo();
|
|
4741
|
+
if (!store.donationsEnabled) return null;
|
|
4742
|
+
|
|
4743
|
+
const donation = await brainerce.createDonation({
|
|
4744
|
+
amount: 180, // number, the gift itself — EXCLUDES feeCoverAmount
|
|
4745
|
+
feeCoverAmount: 6.3, // optional, charged ON TOP of the gift
|
|
4746
|
+
donorEmail: 'sarah@example.com', // required
|
|
4747
|
+
donorName: 'Sarah Cohen', // optional
|
|
4748
|
+
isAnonymous: false, // optional — hides the name on PUBLIC surfaces only
|
|
4749
|
+
tributeType: 'IN_MEMORY', // optional: 'IN_HONOR' | 'IN_MEMORY'; needs tributeName
|
|
4750
|
+
tributeName: 'Avraham Cohen',
|
|
4751
|
+
message: 'From the whole family.', // optional, plain text — never render as HTML
|
|
4752
|
+
returnPath: '/thank-you', // optional PATH on your storefront. A full URL is rejected.
|
|
4753
|
+
});
|
|
4754
|
+
// → {
|
|
4755
|
+
// donationId: 'don_…',
|
|
4756
|
+
// status: 'PENDING', // ⛔ NOT a paid gift
|
|
4757
|
+
// amount: '180.00', // the gift
|
|
4758
|
+
// feeCoverAmount: '6.30',
|
|
4759
|
+
// chargeAmount: '186.30', // what the card is actually charged
|
|
4760
|
+
// currency: 'ILS',
|
|
4761
|
+
// payment: { intentId, clientSecret?, clientSdk?, redirectUrl?, providerType }
|
|
4762
|
+
// }
|
|
4763
|
+
|
|
4764
|
+
const settled = await brainerce.getDonation(donation.donationId);
|
|
4765
|
+
// → {
|
|
4766
|
+
// id, status: 'PENDING' | 'PAID' | 'FAILED' | 'CANCELLED',
|
|
4767
|
+
// amount, feeCoverAmount, currency,
|
|
4768
|
+
// donorName, // null when the gift was marked anonymous
|
|
4769
|
+
// tributeType, tributeName,
|
|
4770
|
+
// paidAt // null until PAID
|
|
4771
|
+
// }
|
|
4772
|
+
```
|
|
4773
|
+
|
|
4774
|
+
Complete `donation.payment` with the provider exactly as you would a checkout intent — `clientSecret` for an embedded SDK, `redirectUrl` for a hosted page — then poll `getDonation()` until `status === 'PAID'`. Both calls are rate limited to 5 requests per minute. The read-back payload carries no failure reason and withholds the donor name on an anonymous gift, so it is safe to render straight onto a public page.
|
|
4775
|
+
|
|
4776
|
+
See [Donation Page](#donation-page) for the full walkthrough and the things it deliberately does not do.
|
|
4777
|
+
|
|
4512
4778
|
### Traffic Analytics (built-in, no GA4 needed)
|
|
4513
4779
|
|
|
4514
|
-
Brainerce has a **native cookieless analytics pipeline**
|
|
4780
|
+
Brainerce has a **native cookieless analytics pipeline** covering visits, visitors, countries, sources, devices and the conversion funnel, visible in the merchant dashboard under **Dashboard → Traffic**. You don't need GA4, Meta Pixel, or any third-party script.
|
|
4515
4781
|
|
|
4516
|
-
**Option A
|
|
4782
|
+
**Option A: Script tag (recommended, zero JS):**
|
|
4517
4783
|
|
|
4518
4784
|
Add one line to your root layout `<head>`:
|
|
4519
4785
|
|
|
@@ -4527,7 +4793,7 @@ Add one line to your root layout `<head>`:
|
|
|
4527
4793
|
|
|
4528
4794
|
The pixel auto-tracks pageviews on load and on every SPA route change, de-dupes consecutive identical paths, and measures active dwell time. Scaffolds from `create-brainerce-store ≥ 1.50` include it automatically.
|
|
4529
4795
|
|
|
4530
|
-
**Option B
|
|
4796
|
+
**Option B: SDK method (JS import, same endpoint):**
|
|
4531
4797
|
|
|
4532
4798
|
```typescript
|
|
4533
4799
|
// Basic pageview — call this on route changes if you're not using t.js
|
|
@@ -4548,9 +4814,9 @@ client.trackEvent({
|
|
|
4548
4814
|
client.trackEvent({ eventType: 'engagement', path: '/products/shoes', engagedMs: 12000 });
|
|
4549
4815
|
```
|
|
4550
4816
|
|
|
4551
|
-
> `trackEvent()` is fire-and-forget
|
|
4817
|
+
> `trackEvent()` is fire-and-forget; errors are silently swallowed so a failed beacon never breaks your storefront. Available in `salesChannelId` and `storeId` modes.
|
|
4552
4818
|
|
|
4553
|
-
> **Analytics always goes directly to the Brainerce API** (`keepalive` beacon), even when you route the rest of the SDK through a same-origin BFF proxy (`proxyMode` / `baseUrl: '/api/store'`). This is deliberate: a beacon relayed through your server would arrive from the server's datacenter IP and mis-geolocate **every** visitor to that region (e.g. a Vercel US function → all visitors show as US). When `proxyMode` is on
|
|
4819
|
+
> **Analytics always goes directly to the Brainerce API** (`keepalive` beacon), even when you route the rest of the SDK through a same-origin BFF proxy (`proxyMode` / `baseUrl: '/api/store'`). This is deliberate: a beacon relayed through your server would arrive from the server's datacenter IP and mis-geolocate **every** visitor to that region (e.g. a Vercel US function → all visitors show as US). When `proxyMode` is on, or `baseUrl` shares the page origin, the SDK auto-targets `https://api.brainerce.com` for beacons; override with `analyticsBaseUrl` only if your API is self-hosted.
|
|
4554
4820
|
|
|
4555
4821
|
**Privacy:** cookieless, no PII stored. The visitor IP is resolved to a country server-side and then discarded. No cookie-consent banner required under GDPR/ePrivacy.
|
|
4556
4822
|
|
|
@@ -4558,7 +4824,7 @@ client.trackEvent({ eventType: 'engagement', path: '/products/shoes', engagedMs:
|
|
|
4558
4824
|
|
|
4559
4825
|
### Analytics (optional, server-side GA4 conversions)
|
|
4560
4826
|
|
|
4561
|
-
If the store has the **Google & YouTube** app installed with a GA4 property connected, Brainerce can send server-side `purchase` conversions via the GA4 Measurement Protocol
|
|
4827
|
+
If the store has the **Google & YouTube** app installed with a GA4 property connected, Brainerce can send server-side `purchase` conversions via the GA4 Measurement Protocol, recovering the 15-40% of conversions client-side `gtag.js` typically loses to ad-blockers, ITP/Safari cookie capping, and the post-payment redirect dropping the page before the browser beacon fires.
|
|
4562
4828
|
|
|
4563
4829
|
For the server-sent purchase to join the shopper's on-page GA4 session (instead of creating a duplicate/orphan user), it needs the same `client_id`/`session_id` gtag.js is using in the browser. Call `loadGoogleAnalytics()` once and the SDK handles the rest:
|
|
4564
4830
|
|
|
@@ -4574,8 +4840,8 @@ await client.addToCart(cart.id, { productId: 'prod_abc', quantity: 1 });
|
|
|
4574
4840
|
|
|
4575
4841
|
### Traffic attribution (automatic, zero-config)
|
|
4576
4842
|
|
|
4577
|
-
The SDK also records where each visit came from
|
|
4578
|
-
and any `utm_source`/`utm_medium`/`utm_campaign
|
|
4843
|
+
The SDK also records where each visit came from, the external referrer host
|
|
4844
|
+
and any `utm_source`/`utm_medium`/`utm_campaign`, as a **last non-direct
|
|
4579
4845
|
touch** (`brainerce_attr` in localStorage, 30-day window). The captured values
|
|
4580
4846
|
are auto-attached to `setCheckoutCustomer()` / `setShippingAddress()` and end
|
|
4581
4847
|
up on the order, powering the dashboard's "orders from ChatGPT / Google / …"
|
|
@@ -4583,12 +4849,12 @@ reporting. Nothing to configure; a value you pass explicitly always wins.
|
|
|
4583
4849
|
|
|
4584
4850
|
What this does:
|
|
4585
4851
|
|
|
4586
|
-
- Idempotently injects `gtag.js` and initializes `dataLayer` (skips injection if you're already loading `gtag.js` yourself
|
|
4587
|
-
- Resolves `client_id`/`session_id` via `gtag('get', measurementId, 'client_id' | 'session_id', cb)
|
|
4852
|
+
- Idempotently injects `gtag.js` and initializes `dataLayer` (skips injection if you're already loading `gtag.js` yourself, so it is safe to call either way).
|
|
4853
|
+
- Resolves `client_id`/`session_id` via `gtag('get', measurementId, 'client_id' | 'session_id', cb)`, Google's documented method, not `_ga` cookie-parsing (which breaks across cookie-format changes and Consent Mode v2 states).
|
|
4588
4854
|
- Auto-attaches the resolved ids to `createCart()`, `addToCart()`, `setCheckoutCustomer()`, and `setShippingAddress()`. An explicit `analyticsClientId`/`analyticsSessionId` you pass to any of those calls always wins over the auto-captured value.
|
|
4589
|
-
- Never throws and never delays a cart/checkout call by more than ~1.5s (configurable via `{ timeoutMs }`)
|
|
4855
|
+
- Never throws and never delays a cart/checkout call by more than ~1.5s (configurable via `{ timeoutMs }`). A blocked or slow `gtag.js`, or a shopper who denied analytics consent, just means the server-side conversion won't stitch. It never breaks checkout.
|
|
4590
4856
|
|
|
4591
|
-
You still need to paste the GA4 **Measurement Protocol API secret** once in the dashboard (**Apps → Google & YouTube → Analytics**)
|
|
4857
|
+
You still need to paste the GA4 **Measurement Protocol API secret** once in the dashboard (**Apps → Google & YouTube → Analytics**). Create it in GA4 Admin → Data Streams → your stream → Measurement Protocol API secrets. Without it (or without `loadGoogleAnalytics()` ever being called), the platform simply skips the server-side event. Nothing breaks; GA4 just doesn't get the extra signal.
|
|
4592
4858
|
|
|
4593
4859
|
> This is entirely optional and separate from Brainerce's [Traffic Analytics](#traffic-analytics-built-in-no-ga4-needed) above, which needs no GA4 account at all.
|
|
4594
4860
|
|
|
@@ -4598,7 +4864,7 @@ You still need to paste the GA4 **Measurement Protocol API secret** once in the
|
|
|
4598
4864
|
|
|
4599
4865
|
`initTracking()` boots every marketing tag the merchant has configured, and `trackMarketingEvent()` reports e-commerce events to all of them at once. Two calls, no per-vendor code.
|
|
4600
4866
|
|
|
4601
|
-
**You do not ask the merchant for tag ids.** They arrive in `getStoreInfo().tracking`, resolved server-side from the marketplace apps the merchant already connected
|
|
4867
|
+
**You do not ask the merchant for tag ids.** They arrive in `getStoreInfo().tracking`, resolved server-side from the marketplace apps the merchant already connected: connecting the **Google & YouTube** app runs GA4 discovery and the measurement id appears on its own; the **Meta Commerce** app does the same for the pixel. Nothing is typed, and the storefront never redeploys: a newly connected app shows up within 5 minutes.
|
|
4602
4868
|
|
|
4603
4869
|
```typescript
|
|
4604
4870
|
const storeInfo = await client.getStoreInfo();
|
|
@@ -4608,7 +4874,7 @@ const storeInfo = await client.getStoreInfo();
|
|
|
4608
4874
|
client.initTracking(storeInfo.tracking);
|
|
4609
4875
|
```
|
|
4610
4876
|
|
|
4611
|
-
Then report what the shopper did
|
|
4877
|
+
Then report what the shopper did, once, in GA4's vocabulary. The SDK translates to each vendor (`dataLayer` push, `fbq` standard event, `ttq` event):
|
|
4612
4878
|
|
|
4613
4879
|
```typescript
|
|
4614
4880
|
client.trackMarketingEvent('view_item', { currency, value: price, items: [item] });
|
|
@@ -4631,11 +4897,11 @@ client.trackMarketingEvent('purchase', {
|
|
|
4631
4897
|
|
|
4632
4898
|
Event names: `view_item` · `view_item_list` · `add_to_cart` · `remove_from_cart` · `view_cart` · `begin_checkout` · `add_payment_info` · `purchase` · `search` · `sign_up`.
|
|
4633
4899
|
|
|
4634
|
-
⛔ **`itemId` must be the SKU.** That is the id Brainerce publishes to the Google Merchant Center and Meta catalog feeds, so it is the only id the ad platforms can match a pixel event against. Sending a product or variant id instead breaks attribution and dynamic remarketing **silently
|
|
4900
|
+
⛔ **`itemId` must be the SKU.** That is the id Brainerce publishes to the Google Merchant Center and Meta catalog feeds, so it is the only id the ad platforms can match a pixel event against. Sending a product or variant id instead breaks attribution and dynamic remarketing **silently**: the events arrive, the platform reports an id its catalog has never seen, and the remarketing audience never builds.
|
|
4635
4901
|
|
|
4636
4902
|
⛔ **Pass `transactionId` on `purchase`.** It becomes GA4's `transaction_id`, Meta's `eventID` and TikTok's `event_id`, so a shopper who refreshes the confirmation page de-duplicates on the vendor's side instead of double-counting the order. Also guard the call yourself (e.g. a `sessionStorage` key per order id) so the send doesn't repeat at all.
|
|
4637
4903
|
|
|
4638
|
-
**Content-Security-Policy.** If your storefront sets a CSP, the tags load but every hit is blocked unless these hosts are in `connect-src
|
|
4904
|
+
**Content-Security-Policy.** If your storefront sets a CSP, the tags load but every hit is blocked unless these hosts are in `connect-src`, a failure that looks exactly like "no sales" in ad reporting:
|
|
4639
4905
|
|
|
4640
4906
|
```
|
|
4641
4907
|
connect-src … https://www.googletagmanager.com https://www.google-analytics.com
|
|
@@ -4645,19 +4911,19 @@ connect-src … https://www.googletagmanager.com https://www.google-analytics.co
|
|
|
4645
4911
|
https://analytics.tiktok.com
|
|
4646
4912
|
```
|
|
4647
4913
|
|
|
4648
|
-
Under `script-src 'strict-dynamic'` you do **not** add these to `script-src
|
|
4914
|
+
Under `script-src 'strict-dynamic'` you do **not** add these to `script-src`, because host allowlists are ignored there, and trust propagates from your nonce'd bundle to the tag scripts it injects.
|
|
4649
4915
|
|
|
4650
|
-
**Consent.** These are advertising tags, unlike Brainerce's cookieless [Traffic Analytics](#traffic-analytics-built-in-no-ga4-needed). If you serve the EEA/UK, gate `initTracking()` behind your consent banner or implement Google Consent Mode v2
|
|
4916
|
+
**Consent.** These are advertising tags, unlike Brainerce's cookieless [Traffic Analytics](#traffic-analytics-built-in-no-ga4-needed). If you serve the EEA/UK, gate `initTracking()` behind your consent banner or implement Google Consent Mode v2. The SDK does not do this for you.
|
|
4651
4917
|
|
|
4652
4918
|
`initTracking()` is idempotent, SSR-safe, and never throws. Both methods no-op when no tag is configured, so they're always safe to call without a feature check.
|
|
4653
4919
|
|
|
4654
|
-
> `trackMarketingEvent()` (ad platforms) is a different method from `trackEvent()` (Brainerce's own cookieless traffic analytics). Call both
|
|
4920
|
+
> `trackMarketingEvent()` (ad platforms) is a different method from `trackEvent()` (Brainerce's own cookieless traffic analytics). Call both; they answer different questions.
|
|
4655
4921
|
|
|
4656
4922
|
---
|
|
4657
4923
|
|
|
4658
4924
|
## Admin API Reference
|
|
4659
4925
|
|
|
4660
|
-
> ⛔ **Server-side only.** The `apiKey` (`brainerce_*`) is a privileged secret
|
|
4926
|
+
> ⛔ **Server-side only.** The `apiKey` (`brainerce_*`) is a privileged secret. NEVER put it in browser code, client bundles, or any code that ships to the user's machine. It belongs in an environment variable on your server. For building the customer-facing storefront, use `salesChannelId` instead (see [Quick Start](#quick-start)).
|
|
4661
4927
|
|
|
4662
4928
|
The Admin API provides full access to store configuration and management features (taxonomy, shipping, team, metafields, etc.) and runs only in server-side code.
|
|
4663
4929
|
|
|
@@ -4754,11 +5020,11 @@ await client.bulkSaveVariants(variableProduct.id, {
|
|
|
4754
5020
|
});
|
|
4755
5021
|
```
|
|
4756
5022
|
|
|
4757
|
-
**GTIN vs MPN:** these are two different identifiers, not interchangeable
|
|
5023
|
+
**GTIN vs MPN:** these are two different identifiers, not interchangeable: GTIN (EAN/UPC/ISBN) is a universal barcode; MPN is manufacturer-specific and only meaningful paired with a brand. Provide GTIN when the product has one; otherwise brand + MPN. A product typically needs one or the other, not both.
|
|
4758
5024
|
|
|
4759
5025
|
### Bulk Product Creation (catalog import)
|
|
4760
5026
|
|
|
4761
|
-
Importing a catalog
|
|
5027
|
+
Importing a catalog (a supplier feed, a CSV/Excel export, a store migration)
|
|
4762
5028
|
should not be thousands of `createProduct` calls. `bulkCreateProducts` takes an
|
|
4763
5029
|
array and returns a **job id**: the work is queued, and the products appear over
|
|
4764
5030
|
the following seconds or minutes.
|
|
@@ -4806,7 +5072,7 @@ while (status.status === 'QUEUED' || status.status === 'RUNNING') {
|
|
|
4806
5072
|
console.log(status.succeeded, status.skipped, status.failed, status.pending);
|
|
4807
5073
|
```
|
|
4808
5074
|
|
|
4809
|
-
`COMPLETED_WITH_ERRORS` means the import finished and some rows failed
|
|
5075
|
+
`COMPLETED_WITH_ERRORS` means the import finished and some rows failed. There
|
|
4810
5076
|
is nothing to re-run. Read the failures instead; each carries the 1-indexed
|
|
4811
5077
|
`row` from the array you submitted, so it maps back to the line of the source
|
|
4812
5078
|
spreadsheet:
|
|
@@ -4846,7 +5112,7 @@ chunks and leaves `finishedAt` null until every one has finished, so a partial
|
|
|
4846
5112
|
result can never read as a finished import.
|
|
4847
5113
|
|
|
4848
5114
|
**Duplicates.** A row whose `sku` or `externalId` already exists in the store is
|
|
4849
|
-
skipped rather than duplicated
|
|
5115
|
+
skipped rather than duplicated, so a batch re-sent after a timeout cannot
|
|
4850
5116
|
create the catalog twice. This is a database-level check, so it still holds days
|
|
4851
5117
|
later and across retries. Rows carrying **neither** a `sku` nor an `externalId`
|
|
4852
5118
|
have nothing to match on and will be created again on a re-send; set
|
|
@@ -4858,13 +5124,13 @@ skipped.
|
|
|
4858
5124
|
taken, the importer appends a suffix (`t-shirt`, `t-shirt-1`, ...) and imports
|
|
4859
5125
|
the row, where `createProduct` returns a 400. Rows colliding with each other
|
|
4860
5126
|
inside the same batch are resolved the same way, in submission order. That is
|
|
4861
|
-
deliberate
|
|
5127
|
+
deliberate, since a spreadsheet with two "T-Shirt" rows should import rather than fail, but
|
|
4862
5128
|
it means the slug you sent is not always the slug you get. Read it back from
|
|
4863
5129
|
the product if you depend on it.
|
|
4864
5130
|
|
|
4865
5131
|
**Channel sync.** By default (`syncMode: 'coalesced'`) the per-product push to
|
|
4866
5132
|
connected sales channels is suppressed during the import and one sync per
|
|
4867
|
-
affected channel is filed at the end
|
|
5133
|
+
affected channel is filed at the end, because connectors are rate-limited per catalog,
|
|
4868
5134
|
and a per-product fan-out would exhaust those limits. Use `syncMode: 'none'` to
|
|
4869
5135
|
write to Brainerce only.
|
|
4870
5136
|
|
|
@@ -4970,13 +5236,13 @@ const swatches = getProductSwatches(product);
|
|
|
4970
5236
|
```
|
|
4971
5237
|
|
|
4972
5238
|
> **Editing or deleting a single option is dashboard-only.** `/api/v1/attributes/:id/options`
|
|
4973
|
-
> exposes exactly two verbs
|
|
5239
|
+
> exposes exactly two verbs: `GET` (list) and `POST` (add). There is no per-option route
|
|
4974
5240
|
> on the public API, so `client.updateAttributeOption()` and `client.deleteAttributeOption()`
|
|
4975
5241
|
> return **404**. The per-option routes exist only on the dashboard API
|
|
4976
5242
|
> (`PUT` / `DELETE /api/stores/:storeId/attributes/:id/options/:optionId`), behind Clerk
|
|
4977
5243
|
> auth. Recolour a swatch or drop an option in the dashboard. Everything else on
|
|
4978
|
-
> attributes
|
|
4979
|
-
> `createAttributeOption`
|
|
5244
|
+
> attributes (create, list, `updateAttribute`, `deleteAttribute`, `getAttributeOptions`,
|
|
5245
|
+
> `createAttributeOption`) works from the SDK as shown above.
|
|
4980
5246
|
|
|
4981
5247
|
### Shipping Configuration
|
|
4982
5248
|
|
|
@@ -5031,16 +5297,25 @@ const mobileOnlyZone = await client.createShippingZone({
|
|
|
5031
5297
|
const rates = await client.getZoneShippingRates('zone_id');
|
|
5032
5298
|
await client.createZoneShippingRate('zone_id', {
|
|
5033
5299
|
name: 'Standard Shipping',
|
|
5034
|
-
type: '
|
|
5035
|
-
|
|
5036
|
-
|
|
5300
|
+
type: 'FLAT_RATE',
|
|
5301
|
+
rateConfig: { amount: 5.99 },
|
|
5302
|
+
minDeliveryDays: 3,
|
|
5303
|
+
maxDeliveryDays: 5,
|
|
5037
5304
|
});
|
|
5038
5305
|
```
|
|
5039
5306
|
|
|
5307
|
+
**`type` is one of `FLAT_RATE`, `FREE`, `WEIGHT_BASED`, `PRICE_BASED`,
|
|
5308
|
+
`LOCAL_PICKUP`** — uppercase, and `FLAT_RATE` rather than `FLAT`. The price
|
|
5309
|
+
goes inside `rateConfig`, whose shape follows `type`: `FLAT_RATE` takes
|
|
5310
|
+
`{ amount }`, the two tiered types take tier arrays, and `FREE` and
|
|
5311
|
+
`LOCAL_PICKUP` take none. Unknown top-level properties are rejected rather
|
|
5312
|
+
than ignored, so a leftover `price` or `estimatedDays` fails the call with 400
|
|
5313
|
+
even though everything else is correct.
|
|
5314
|
+
|
|
5040
5315
|
### App Store Shipping (live carrier rates)
|
|
5041
5316
|
|
|
5042
|
-
Once a merchant installs a shipping app from the Brainerce App Store
|
|
5043
|
-
future carrier
|
|
5317
|
+
Once a merchant installs a shipping app from the Brainerce App Store (EasyPost, Shippo, or any
|
|
5318
|
+
future carrier) and connects their own carrier account, live rates appear automatically at
|
|
5044
5319
|
checkout. Billing goes directly to the merchant's carrier account.
|
|
5045
5320
|
|
|
5046
5321
|
Every carrier app implements the same Brainerce shipping contract, so this code is identical
|
|
@@ -5062,11 +5337,11 @@ console.log(label.carrier); // e.g. 'USPS', 'UPS', 'FedEx'
|
|
|
5062
5337
|
console.log(label.labelFormat); // What the carrier actually produced
|
|
5063
5338
|
```
|
|
5064
5339
|
|
|
5065
|
-
The `trackingNumber` is stored on the `Order` automatically
|
|
5340
|
+
The `trackingNumber` is stored on the `Order` automatically, and customers see it in their
|
|
5066
5341
|
order history without any extra integration work.
|
|
5067
5342
|
|
|
5068
5343
|
**Buy the service the shopper paid for.** `order.shippingSelection` records the live carrier
|
|
5069
|
-
service that was sold at checkout
|
|
5344
|
+
service that was sold at checkout as `{ carrier, service, methodName, amount }`, or `null` when
|
|
5070
5345
|
the order sold a flat-rate/zone rate and there is nothing to match. Rate ids do not survive a
|
|
5071
5346
|
re-quote, so re-find it on `carrier` + `service`, trimmed and lower-cased:
|
|
5072
5347
|
|
|
@@ -5084,12 +5359,12 @@ const preferred = paidFor
|
|
|
5084
5359
|
|
|
5085
5360
|
Buying a cheaper, slower service than the one the shopper was charged for is a silent
|
|
5086
5361
|
downgrade of what they bought. When the paid-for service is not in the fresh quote, say so and
|
|
5087
|
-
let a human choose
|
|
5362
|
+
let a human choose, and do not substitute one automatically.
|
|
5088
5363
|
|
|
5089
5364
|
**Tracking updates are automatic.** Once the label exists, the carrier's tracking webhooks
|
|
5090
|
-
flow back through the shipping app and move the shipment through its lifecycle
|
|
5365
|
+
flow back through the shipping app and move the shipment through its lifecycle: in transit,
|
|
5091
5366
|
out for delivery, delivered. On delivery the order is completed and the customer notification
|
|
5092
|
-
fires. You never poll for status
|
|
5367
|
+
fires. You never poll for status; read the history when you want to show it:
|
|
5093
5368
|
|
|
5094
5369
|
```typescript
|
|
5095
5370
|
const shipments = await admin.getOrderShipments(orderId);
|
|
@@ -5104,9 +5379,37 @@ for (const s of shipments) {
|
|
|
5104
5379
|
```
|
|
5105
5380
|
|
|
5106
5381
|
**Quote immediately before you buy.** `getOrderShippingRates()` is what creates the shipment
|
|
5107
|
-
at the carrier
|
|
5382
|
+
at the carrier, which the rate id points at, and carriers do not allow amending one afterwards.
|
|
5108
5383
|
A rate held from an earlier call may no longer be purchasable.
|
|
5109
5384
|
|
|
5385
|
+
### Return labels
|
|
5386
|
+
|
|
5387
|
+
Buy a return label the merchant sends to their customer to print — the customer ships, the
|
|
5388
|
+
merchant receives. This is a distinct route from the rate-shop-then-buy flow above: there is
|
|
5389
|
+
no `rateId`, because a return is a different kind of shipment at the carrier, fixed as a return
|
|
5390
|
+
when it is created and never amendable afterwards. It quotes and buys in one call.
|
|
5391
|
+
|
|
5392
|
+
```typescript
|
|
5393
|
+
const label = await admin.createReturnLabel('store_abc', orderId, {
|
|
5394
|
+
reason: 'Wrong size',
|
|
5395
|
+
returnForShipmentId: 'shp_original123', // optional — the outbound shipment this reverses
|
|
5396
|
+
labelFormat: 'PDF', // 'PDF' | 'PNG' | 'ZPL' | 'EPL'
|
|
5397
|
+
preferredCarrier: 'USPS', // optional — omit both to let the cheapest rate win
|
|
5398
|
+
preferredService: 'GroundAdvantage',
|
|
5399
|
+
});
|
|
5400
|
+
|
|
5401
|
+
console.log(label.labelUrl); // Label file URL for printing
|
|
5402
|
+
console.log(label.trackingNumber);
|
|
5403
|
+
console.log(label.rate, label.rateCurrency); // What the merchant's carrier account was billed
|
|
5404
|
+
```
|
|
5405
|
+
|
|
5406
|
+
Requires admin mode (`apiKey`) with `FULFILL_ORDERS` permission — it spends the store's carrier
|
|
5407
|
+
balance, same as `createShippingLabel`. Unlike the other shipment calls on this page, this one
|
|
5408
|
+
takes `storeId` as an explicit first argument rather than deriving it from the API key, so pass
|
|
5409
|
+
it even in admin mode. There is no mechanism for charging the customer for return postage —
|
|
5410
|
+
`rate` reports what the merchant's account paid so it can be deducted from a refund
|
|
5411
|
+
deliberately, never absorbed silently.
|
|
5412
|
+
|
|
5110
5413
|
### Cross-border shipments
|
|
5111
5414
|
|
|
5112
5415
|
Customs declarations are handled for you: the platform builds one from the order's line items
|
|
@@ -5116,8 +5419,8 @@ merchandise.
|
|
|
5116
5419
|
|
|
5117
5420
|
One case needs the merchant: a US-origin export where any single commodity line exceeds
|
|
5118
5421
|
**$2,500** cannot use the ordinary EEI exemption. The exporter must file with AES and supply
|
|
5119
|
-
the resulting ITN. Brainerce deliberately does **not** assert the exemption on those shipments
|
|
5120
|
-
|
|
5422
|
+
the resulting ITN. Brainerce deliberately does **not** assert the exemption on those shipments.
|
|
5423
|
+
It is a declaration to US Customs, not a formality, so the carrier will refuse the label until
|
|
5121
5424
|
a real citation is provided.
|
|
5122
5425
|
|
|
5123
5426
|
### Tax Configuration
|
|
@@ -5149,6 +5452,58 @@ await client.createTaxRate({
|
|
|
5149
5452
|
});
|
|
5150
5453
|
```
|
|
5151
5454
|
|
|
5455
|
+
#### Two taxes on the same sale (`stackable`)
|
|
5456
|
+
|
|
5457
|
+
By default exactly **one** rate applies to a line: the most specific match wins
|
|
5458
|
+
(postal code beats region beats country) and every other match is discarded.
|
|
5459
|
+
`stackable: true` opts a rate into being **summed** with the other stackable
|
|
5460
|
+
rates that match the same address, all charged on the same pre-tax base. Never
|
|
5461
|
+
tax-on-tax — Quebec stopped compounding QST on GST in 2013.
|
|
5462
|
+
|
|
5463
|
+
Canada is the case you will hit. A country-level GST row plus a province row:
|
|
5464
|
+
|
|
5465
|
+
```typescript
|
|
5466
|
+
await client.createTaxRate({ name: 'GST', rate: 5, country: 'CA', stackable: true });
|
|
5467
|
+
await client.createTaxRate({
|
|
5468
|
+
name: 'QST',
|
|
5469
|
+
rate: 9.975,
|
|
5470
|
+
country: 'CA',
|
|
5471
|
+
region: 'QC',
|
|
5472
|
+
stackable: true,
|
|
5473
|
+
});
|
|
5474
|
+
// A Quebec checkout is taxed 14.975% and taxBreakdown.breakdown has TWO rows.
|
|
5475
|
+
|
|
5476
|
+
// HST already contains the federal 5%, so it is ONE rate left non-stackable —
|
|
5477
|
+
// it wins alone at 13% and never adds the GST row underneath it.
|
|
5478
|
+
await client.createTaxRate({ name: 'HST', rate: 13, country: 'CA', region: 'ON' });
|
|
5479
|
+
```
|
|
5480
|
+
|
|
5481
|
+
`stackable` defaults to `false`, so every rate that existed before this field
|
|
5482
|
+
keeps the most-specific-wins behaviour unchanged. Stacking never crosses a tax
|
|
5483
|
+
class either: a class-specific rate **replaces** the Standard rates rather than
|
|
5484
|
+
adding to them, so a class that needs GST plus a reduced QST needs both rows
|
|
5485
|
+
created in that class.
|
|
5486
|
+
|
|
5487
|
+
#### Country presets
|
|
5488
|
+
|
|
5489
|
+
`applyTaxPreset` writes a country's whole rate table in one call, already
|
|
5490
|
+
flagged, instead of thirteen provinces entered by hand:
|
|
5491
|
+
|
|
5492
|
+
```typescript
|
|
5493
|
+
const presets = await client.getTaxPresets();
|
|
5494
|
+
// [{ key: 'CA', country: 'CA', label: 'Canada — GST, HST, PST, QST', rateCount: 11, … }]
|
|
5495
|
+
|
|
5496
|
+
const { created } = await client.applyTaxPreset('CA'); // created === 11
|
|
5497
|
+
```
|
|
5498
|
+
|
|
5499
|
+
It writes federal GST 5% country-wide, one combined HST row for ON/NB/NL/NS/PE,
|
|
5500
|
+
and PST/RST/QST for BC/SK/MB/QC on top of the GST. Alberta and the territories
|
|
5501
|
+
need no row. It throws **409** when the store already has rates for that
|
|
5502
|
+
country — delete those first if you meant to replace them — so a double call
|
|
5503
|
+
cannot double every province. Rates land in the Standard tax class.
|
|
5504
|
+
|
|
5505
|
+
Brainerce does not register the store for GST/HST and does not file returns.
|
|
5506
|
+
|
|
5152
5507
|
### Tax Classes
|
|
5153
5508
|
|
|
5154
5509
|
Tax classes let you charge different rates for different product types (e.g.
|
|
@@ -5189,7 +5544,7 @@ await client.deleteTaxClass(food.id);
|
|
|
5189
5544
|
|
|
5190
5545
|
**Storefront (public, no API key).** A storefront lists classes in `storeId`
|
|
5191
5546
|
mode **or** vibe-coded mode (`salesChannelId: 'vc_*'`, gated on the
|
|
5192
|
-
`products:read` scope every connection already has)
|
|
5547
|
+
`products:read` scope every connection already has), returning storefront-safe fields
|
|
5193
5548
|
only (for a "9% VAT" transparency badge):
|
|
5194
5549
|
|
|
5195
5550
|
```typescript
|
|
@@ -5254,7 +5609,7 @@ await client.deleteRegion(eu.id);
|
|
|
5254
5609
|
|
|
5255
5610
|
**Storefront (public, no API key).** A storefront fetches regions in `storeId`
|
|
5256
5611
|
mode **or** vibe-coded mode (`salesChannelId: 'vc_*'`, gated on the
|
|
5257
|
-
`products:read` scope every connection already has)
|
|
5612
|
+
`products:read` scope every connection already has), returning only active regions and only
|
|
5258
5613
|
storefront-safe fields. `getStoreRegions()`, `getStoreRegion()`, and
|
|
5259
5614
|
`getAutoRegion()` all work in both modes:
|
|
5260
5615
|
|
|
@@ -5304,18 +5659,18 @@ await client.setMetafieldPlatforms('def_id', {
|
|
|
5304
5659
|
|
|
5305
5660
|
### Per-Channel Publishing (Categories / Tags / Brands / Custom Fields)
|
|
5306
5661
|
|
|
5307
|
-
Each of these entities can be **gated per vibe-coded site
|
|
5662
|
+
Each of these entities can be **gated per vibe-coded site**: merchants
|
|
5308
5663
|
choose which storefronts see which categories, tags, brands, and custom
|
|
5309
5664
|
fields. Mirrors the pattern already used by Products and Coupons.
|
|
5310
5665
|
|
|
5311
|
-
**Visibility semantics
|
|
5666
|
+
**Visibility semantics, explicit opt-in:** an entity is visible to a
|
|
5312
5667
|
vibe-coded site **only if** it has been explicitly published to that
|
|
5313
5668
|
connection. Entities with no publish rows are invisible to every vibe-coded
|
|
5314
5669
|
site, including in product responses (the related categories/brands/tags
|
|
5315
5670
|
arrays and the metafields array on each product are filtered the same way).
|
|
5316
5671
|
Merchants publish through the dashboard's per-row Platforms cell; for
|
|
5317
5672
|
categories, tags and brands the admin SDK below does the same thing. Custom
|
|
5318
|
-
fields are the exception
|
|
5673
|
+
fields are the exception; see the note under the snippet.
|
|
5319
5674
|
|
|
5320
5675
|
```typescript
|
|
5321
5676
|
// Publish a product to a sales channel (accepts record ID or vc_* connection ID)
|
|
@@ -5341,21 +5696,22 @@ const cat = await client.getCategory('cat_id');
|
|
|
5341
5696
|
cat.channelPublishes; // [{ salesChannel: { id, name, connectionId } }, ...]
|
|
5342
5697
|
```
|
|
5343
5698
|
|
|
5344
|
-
> **Custom fields are the exception
|
|
5699
|
+
> **Custom fields are the exception: publish them in the dashboard.**
|
|
5345
5700
|
> `client.publishMetafieldDefinitionToVibeCodedSite()` and
|
|
5346
5701
|
> `client.unpublishMetafieldDefinitionFromVibeCodedSite()` target
|
|
5347
5702
|
> `/api/v1/metafield-definitions/:id/publish-vibe-coded`, which the public API does not
|
|
5348
5703
|
> expose; both return **404**. Only the dashboard API carries those routes
|
|
5349
5704
|
> (`POST /api/stores/:storeId/metafield-definitions/:id/publish` and `…/unpublish`),
|
|
5350
|
-
> behind Clerk auth. **Reading is unaffected
|
|
5705
|
+
> behind Clerk auth. **Reading is unaffected**: `getMetafieldDefinitions()` and
|
|
5351
5706
|
> `getMetafieldDefinition()` both return `channelPublishes` exactly like the other three
|
|
5352
5707
|
> entity types, so you can still see which sites a custom field is published to; you just
|
|
5353
5708
|
> cannot change it from the SDK.
|
|
5354
5709
|
|
|
5355
5710
|
> **`vibeCodedPublishes` and its `connection` sub-key are deprecated.** Both are
|
|
5356
|
-
> still emitted as back-compat aliases of `channelPublishes` /
|
|
5357
|
-
> are
|
|
5358
|
-
> the customer section below
|
|
5711
|
+
> still emitted as permanent back-compat aliases of `channelPublishes` /
|
|
5712
|
+
> `salesChannel` — they are not scheduled for removal. Read
|
|
5713
|
+
> `channelPublishes[].salesChannel` in new code; the customer section below
|
|
5714
|
+
> already does.
|
|
5359
5715
|
|
|
5360
5716
|
**Cross-account isolation:** publishing only succeeds when the entity and the
|
|
5361
5717
|
target vibe-coded connection both belong to the same account. Cross-account
|
|
@@ -5366,12 +5722,12 @@ exist for that account).
|
|
|
5366
5722
|
|
|
5367
5723
|
Customers use the same publish/unpublish shape, with one important difference:
|
|
5368
5724
|
**you almost never have to call it.** A customer is attached to a channel
|
|
5369
|
-
automatically the moment they are seen on it
|
|
5725
|
+
automatically the moment they are seen on it, when they register, sign in
|
|
5370
5726
|
(including via OAuth), or complete a checkout there.
|
|
5371
5727
|
|
|
5372
5728
|
There is one customer record per store, shared by every channel
|
|
5373
5729
|
(`@@unique(storeId, email)`), so the same person shopping two of your
|
|
5374
|
-
storefronts stays one customer with two channel rows
|
|
5730
|
+
storefronts stays one customer with two channel rows, never a duplicate.
|
|
5375
5731
|
|
|
5376
5732
|
```typescript
|
|
5377
5733
|
// Attach / detach by hand — for migrations and corrections only
|
|
@@ -5402,7 +5758,7 @@ not prevent that person from buying on that storefront, and the row comes back
|
|
|
5402
5758
|
the next time they sign in or order there. There is no API to bar a customer
|
|
5403
5759
|
from a sales channel.
|
|
5404
5760
|
|
|
5405
|
-
### Store Team Management
|
|
5761
|
+
### Store Team Management: dashboard-only
|
|
5406
5762
|
|
|
5407
5763
|
Each store has its own team with roles (`OWNER`, `MANAGER`, `STAFF`, `VIEWER`) and
|
|
5408
5764
|
granular permissions, including per-sales-channel scoping. **Managing it is a dashboard
|
|
@@ -5425,12 +5781,12 @@ members in the dashboard.
|
|
|
5425
5781
|
|
|
5426
5782
|
> **The older account-level methods are not a substitute for this.** `getTeamMembers`,
|
|
5427
5783
|
> `getTeamInvitations`, `inviteTeamMember`, `resendTeamInvitation`, `revokeTeamInvitation`,
|
|
5428
|
-
> `updateTeamMemberRole` and `removeTeamMember` do still reach `/api/v1/team
|
|
5784
|
+
> `updateTeamMemberRole` and `removeTeamMember` do still reach `/api/v1/team/…`, but they
|
|
5429
5785
|
> manage the **account** team, not a store's. They will not invite anyone to a store or
|
|
5430
5786
|
> scope a member to a sales channel; only the dashboard does that.
|
|
5431
5787
|
>
|
|
5432
5788
|
> **For the account team, they remain the supported call.** All seven are tagged
|
|
5433
|
-
> `@deprecated`, which records an intent to retire them
|
|
5789
|
+
> `@deprecated`, which records an intent to retire them, not a migration you can perform
|
|
5434
5790
|
> today. There is no API-key replacement: the store-level methods named above are
|
|
5435
5791
|
> dashboard-only. Keep using these until an API-key route ships, and expect the tag to
|
|
5436
5792
|
> outlive this note.
|
|
@@ -5594,7 +5950,7 @@ await client.updateAttachment(storeId, productId, attachment.id, { position: 1 }
|
|
|
5594
5950
|
await client.detachModifierGroup(storeId, productId, attachment.id);
|
|
5595
5951
|
```
|
|
5596
5952
|
|
|
5597
|
-
`null` on an override means "inherit from the group default"; any non-null value (including `0` or `false`) wins. `modifierGroupId` and `variantId` are immutable on `updateAttachment
|
|
5953
|
+
`null` on an override means "inherit from the group default"; any non-null value (including `0` or `false`) wins. `modifierGroupId` and `variantId` are immutable on `updateAttachment`; to swap a group, detach and re-attach.
|
|
5598
5954
|
|
|
5599
5955
|
### Review Moderation
|
|
5600
5956
|
|
|
@@ -5646,6 +6002,66 @@ await client.showProductReviewImage('revimg_123', storeId);
|
|
|
5646
6002
|
> stores that turned approval on. Poll `adminListProductReviews(productId, { visibility: 'all' })`
|
|
5647
6003
|
> and treat any image with `approvedAt: null` as the queue.
|
|
5648
6004
|
|
|
6005
|
+
### Translations Management
|
|
6006
|
+
|
|
6007
|
+
Six admin methods for reading and writing the same `translations` JSON blob the
|
|
6008
|
+
dashboard's own translation editor uses — this is API-key access to the
|
|
6009
|
+
dashboard's persistence path, not a parallel system. Use it to bulk-import
|
|
6010
|
+
pre-translated content (e.g. from a TMS export) or to automate locale
|
|
6011
|
+
coverage without a human opening the dashboard. Needs `products:read` /
|
|
6012
|
+
`products:write` (or the equivalent scope for the target entity type).
|
|
6013
|
+
|
|
6014
|
+
Covers 18 entity types: `store`, `product`, `category`, `brand`, `tag`,
|
|
6015
|
+
`variant`, `attribute`, `attributeOption`, `metafield`, `metafieldDefinition`,
|
|
6016
|
+
`contactForm`, `contactFormField`, `modifierGroup`, `modifier`, `bundleOffer`,
|
|
6017
|
+
`orderBump`, `discountRule`, `blogPost`.
|
|
6018
|
+
|
|
6019
|
+
```typescript
|
|
6020
|
+
// Pre-flight: which entity types/locales still need coverage?
|
|
6021
|
+
const status = await client.getTranslationStatus(storeId, ['he', 'fr']);
|
|
6022
|
+
const blogHe = status.find((s) => s.entityType === 'blogPost' && s.locale === 'he');
|
|
6023
|
+
console.log(`${blogHe?.missing} blog posts still need Hebrew`);
|
|
6024
|
+
|
|
6025
|
+
// Read every persisted translation for one entity
|
|
6026
|
+
const translations = await client.getTranslations(storeId, 'product', productId);
|
|
6027
|
+
console.log(translations.he?.name);
|
|
6028
|
+
|
|
6029
|
+
// Bulk-import a pre-translated blog post (e.g. from a TMS export). Only the
|
|
6030
|
+
// fields valid for `entityType` are persisted — others are silently ignored —
|
|
6031
|
+
// and this is a merge: omitted fields leave any existing translation untouched.
|
|
6032
|
+
await client.setTranslation(storeId, 'blogPost', postId, 'fr', {
|
|
6033
|
+
title: 'Le titre en français',
|
|
6034
|
+
excerpt: "L'extrait en français",
|
|
6035
|
+
content: '<p>Le contenu en français</p>',
|
|
6036
|
+
});
|
|
6037
|
+
|
|
6038
|
+
// Remove one locale's translation (base fields are unaffected)
|
|
6039
|
+
await client.deleteTranslation(storeId, 'blogPost', postId, 'fr');
|
|
6040
|
+
|
|
6041
|
+
// AI-translate a single entity inline — only fills fields still empty for
|
|
6042
|
+
// the target locale; never overwrites an existing translated value.
|
|
6043
|
+
const result = await client.aiTranslateSingle(storeId, {
|
|
6044
|
+
entityType: 'product',
|
|
6045
|
+
entityId: productId,
|
|
6046
|
+
targetLocale: 'he',
|
|
6047
|
+
});
|
|
6048
|
+
|
|
6049
|
+
// Bulk AI-translate: enqueues a background job per entity (returns the count
|
|
6050
|
+
// queued, not finished translations — poll getTranslationStatus for results).
|
|
6051
|
+
// Omit entityIds to target every entity of that type missing the locale.
|
|
6052
|
+
const { queued } = await client.aiTranslateBulk(storeId, {
|
|
6053
|
+
entityType: 'blogPost',
|
|
6054
|
+
targetLocale: 'fr',
|
|
6055
|
+
});
|
|
6056
|
+
```
|
|
6057
|
+
|
|
6058
|
+
> `aiTranslateBulk` only accepts entity types with a clear store scope:
|
|
6059
|
+
> `product`, `category`, `brand`, `tag`, `attribute`, `modifierGroup`,
|
|
6060
|
+
> `metafieldDefinition`, `blogPost`. The remaining entity types (e.g. `variant`,
|
|
6061
|
+
> `attributeOption`) are still reachable via `setTranslation` /
|
|
6062
|
+
> `aiTranslateSingle` — they translate alongside their parent instead of as a
|
|
6063
|
+
> top-level bulk target.
|
|
6064
|
+
|
|
5649
6065
|
---
|
|
5650
6066
|
|
|
5651
6067
|
## Complete Page Examples
|
|
@@ -6041,7 +6457,7 @@ export default function CartPage() {
|
|
|
6041
6457
|
|
|
6042
6458
|
### Checkout Page
|
|
6043
6459
|
|
|
6044
|
-
> **RECOMMENDED:** Use this unified pattern
|
|
6460
|
+
> **RECOMMENDED:** Use this unified pattern; the `smart*` methods handle both guest and logged-in users.
|
|
6045
6461
|
|
|
6046
6462
|
```typescript
|
|
6047
6463
|
'use client';
|
|
@@ -6198,6 +6614,7 @@ export default function CheckoutPage() {
|
|
|
6198
6614
|
> - Split the page into two phases. Fetching rates and completing the order in one submit means the shopper never gets to choose, and you silently charge whichever rate came back first.
|
|
6199
6615
|
> - `email` is required on `setShippingAddress`, for logged-in shoppers too. It is validated before any service code runs, so the server cannot fill it in from the customer record.
|
|
6200
6616
|
> - Guest session cart is created automatically by `smart*` methods
|
|
6617
|
+
> - A gift-card field belongs on the shipping phase, before the order is placed. It does not change the total shown here — it reduces `checkout.providerAmountDue`, which you render as a separate "Amount due" line below the total. See [Gift card redemption flow](#gift-card-redemption-flow)
|
|
6201
6618
|
> - Call `client.onCheckoutComplete()` after successful payment to clear the session cart
|
|
6202
6619
|
> - Call `client.syncCartOnLogin()` when a user logs in to merge their guest cart
|
|
6203
6620
|
|
|
@@ -6644,7 +7061,7 @@ try {
|
|
|
6644
7061
|
|
|
6645
7062
|
Submit contact-form messages from your storefront. Inquiries show up for the merchant at `Customers → Inquiries` in the dashboard; the merchant's reply is emailed back to the customer.
|
|
6646
7063
|
|
|
6647
|
-
**Simple (legacy
|
|
7064
|
+
**Simple (legacy, always supported):**
|
|
6648
7065
|
|
|
6649
7066
|
```typescript
|
|
6650
7067
|
await brainerce.createInquiry({
|
|
@@ -6674,7 +7091,7 @@ const forms = await brainerce.contactForms.list();
|
|
|
6674
7091
|
// → [{ key, name, isDefault }, ...]
|
|
6675
7092
|
```
|
|
6676
7093
|
|
|
6677
|
-
**Rate limit:** 3 submissions per 60 seconds per IP. Include a hidden honeypot field (and do not submit it)
|
|
7094
|
+
**Rate limit:** 3 submissions per 60 seconds per IP. Include a hidden honeypot field (and do not submit it), since bots that auto-fill every input will be rejected.
|
|
6678
7095
|
|
|
6679
7096
|
**A form keyed `newsletter` is still an inquiry.** The key is a label, not a behaviour: the submission files a message and never touches marketing consent, so that address can never receive a campaign. For a mailing list, use [Newsletter Signup](#newsletter-signup-marketing-opt-in).
|
|
6680
7097
|
|
|
@@ -6696,17 +7113,17 @@ await brainerce.marketing.subscribe({
|
|
|
6696
7113
|
|
|
6697
7114
|
Also accepts `firstName`, `lastName`, and `sourceMetadata` (referrer, UTM params, the page the popup fired on).
|
|
6698
7115
|
|
|
6699
|
-
**⛔ It does not subscribe anyone.** The contact is created and mailed a confirmation link; the address is unmailable
|
|
7116
|
+
**⛔ It does not subscribe anyone.** The contact is created and mailed a confirmation link; the address is unmailable, and invisible to every campaign audience, until the recipient clicks it. Render **"Check your email to confirm, including your spam folder"** on success, never "You're subscribed". The spam-folder half matters: a confirmation filtered there is the commonest reason a signup never converts, and the 24-hour resend cooldown means no second copy arrives. Single opt-in is not available: without the click, anyone could subscribe anyone else's address.
|
|
6700
7117
|
|
|
6701
|
-
**⛔ The response carries no information.** `{ ok: true }` is returned identically for a brand-new address, one that confirmed months ago, one inside its 24-hour resend cooldown, and one suppressed after a hard bounce
|
|
7118
|
+
**⛔ The response carries no information.** `{ ok: true }` is returned identically for a brand-new address, one that confirmed months ago, one inside its 24-hour resend cooldown, and one suppressed after a hard bounce. Otherwise the form would become a way to test who shops at this store. Show one message for every success; there is no branch to write.
|
|
6702
7119
|
|
|
6703
|
-
**Rate limit:** 3 requests per 60 seconds per IP, plus one confirmation email per address per store per 24 hours. A submission inside that cooldown still returns `{ ok: true }` and silently sends nothing
|
|
7120
|
+
**Rate limit:** 3 requests per 60 seconds per IP, plus one confirmation email per address per store per 24 hours. A submission inside that cooldown still returns `{ ok: true }` and silently sends nothing; do not treat it as a failure or retry it.
|
|
6704
7121
|
|
|
6705
7122
|
**Locale:** pass it on a multi-language storefront, or the confirmation email falls back to the store's language. `he` and `en` are written; anything else gets English.
|
|
6706
7123
|
|
|
6707
7124
|
**No discount code is minted.** For a "10% off your first order" popup, the merchant creates one coupon with the `customer_first_order` condition and you display that fixed code after a successful call.
|
|
6708
7125
|
|
|
6709
|
-
The contact appears at `Customers` in the dashboard immediately, with **Accepts marketing** off; it flips on at confirmation. It is an ordinary guest customer record
|
|
7126
|
+
The contact appears at `Customers` in the dashboard immediately, with **Accepts marketing** off; it flips on at confirmation. It is an ordinary guest customer record with no password and no account, and is the same row if that person later registers or checks out.
|
|
6710
7127
|
|
|
6711
7128
|
---
|
|
6712
7129
|
|
|
@@ -6725,9 +7142,9 @@ await brainerce.stockAlerts.subscribe({
|
|
|
6725
7142
|
// → { ok: true }
|
|
6726
7143
|
```
|
|
6727
7144
|
|
|
6728
|
-
**⛔ It is not a subscription.** One email, about one item, carrying a link that stops it. No customer account is created and no marketing consent is granted. Label the button **"Email me when it's back"**, never "Subscribe"
|
|
7145
|
+
**⛔ It is not a subscription.** One email, about one item, carrying a link that stops it. No customer account is created and no marketing consent is granted. Label the button **"Email me when it's back"**, never "Subscribe", and because it grants no consent, never hide it from a shopper who unsubscribed from your marketing.
|
|
6729
7146
|
|
|
6730
|
-
**⛔ Render it only when `getStoreInfo().stockAlertsEnabled !== false`, the item is out of stock, AND it cannot be backordered.** Requests for anything else
|
|
7147
|
+
**⛔ Render it only when `getStoreInfo().stockAlertsEnabled !== false`, the item is out of stock, AND it cannot be backordered.** Requests for anything else (a storefront whose merchant switched the feature off, an in-stock item, a backorderable one, an untracked one, an unknown product id) are silently ignored, so a button in the wrong place looks like it worked and does nothing.
|
|
6731
7148
|
|
|
6732
7149
|
```typescript
|
|
6733
7150
|
const store = await brainerce.getStoreInfo();
|
|
@@ -6743,11 +7160,11 @@ const canOfferStockAlert =
|
|
|
6743
7160
|
|
|
6744
7161
|
`backorderMode` is on `InventoryInfo` from SDK 1.61; older backends omit it, so treat `undefined` as `'NONE'`.
|
|
6745
7162
|
|
|
6746
|
-
The merchant controls the switch
|
|
7163
|
+
The merchant controls the switch, and how many people are emailed per unit restocked, under **Channel settings → Inventory**, alongside the low-stock warning.
|
|
6747
7164
|
|
|
6748
7165
|
**⛔ Pass `variantId` on every variable product.** Without it the alert waits on the product as a whole, so a shopper who wanted the medium is mailed when the small returns and arrives to find their size still gone.
|
|
6749
7166
|
|
|
6750
|
-
**⛔ The response carries no information.** `{ ok: true }` is returned identically for a new request, a duplicate, an unknown product, an item already in stock, and an address suppressed after a hard bounce
|
|
7167
|
+
**⛔ The response carries no information.** `{ ok: true }` is returned identically for a new request, a duplicate, an unknown product, an item already in stock, and an address suppressed after a hard bounce. Otherwise the button would become a way to read the store's stock levels. Show one message for every success; there is no branch to write.
|
|
6751
7168
|
|
|
6752
7169
|
**Sending is not immediate, and not to everyone.** Availability is `total - reserved`, so an expiring cart briefly lifts a sold-out item above zero; the alert waits for stock to hold for a few minutes, then goes out in waves sized to the units that came back (500 waiting and 3 units restocked is roughly 9 emails, oldest request first). A shopper can therefore sit through a restock without hearing, so never promise "you'll be the first to know".
|
|
6753
7170
|
|
|
@@ -6755,15 +7172,70 @@ The merchant controls the switch — and how many people are emailed per unit re
|
|
|
6755
7172
|
|
|
6756
7173
|
**Locale:** pass it on a multi-language storefront, or the alert falls back to the store's language. `he` and `en` are written; anything else gets English.
|
|
6757
7174
|
|
|
6758
|
-
**What it does not do:** no SMS or WhatsApp, no price-drop alerts, and no merchant-editable template
|
|
7175
|
+
**What it does not do:** no SMS or WhatsApp, no price-drop alerts, and no merchant-editable template. The body is fixed so it can never start carrying a discount code, which would turn a transactional message into a marketing one needing an unsubscribe link it does not have.
|
|
6759
7176
|
|
|
6760
7177
|
The merchant reads the demand at `Products → Back-in-Stock Waitlist`: products ranked by how many people are waiting, with the addresses behind each number. There is no way to mail those people anything else from there, by design.
|
|
6761
7178
|
|
|
6762
7179
|
---
|
|
6763
7180
|
|
|
7181
|
+
## Donation Page
|
|
7182
|
+
|
|
7183
|
+
**SDK >= 2.1.** A donation page, for a store that takes gifts as well as — or instead of — selling things.
|
|
7184
|
+
|
|
7185
|
+
```typescript
|
|
7186
|
+
// 1. Gate the page. This one does not auto-hide.
|
|
7187
|
+
const store = await brainerce.getStoreInfo();
|
|
7188
|
+
if (!store.donationsEnabled) return null;
|
|
7189
|
+
|
|
7190
|
+
// 2. Start the donation.
|
|
7191
|
+
const donation = await brainerce.createDonation({
|
|
7192
|
+
amount: 180, // the gift
|
|
7193
|
+
feeCoverAmount: 6.3, // only when the donor ticked "cover the fee"
|
|
7194
|
+
donorEmail: 'sarah@example.com',
|
|
7195
|
+
donorName: 'Sarah Cohen',
|
|
7196
|
+
isAnonymous: false,
|
|
7197
|
+
tributeType: 'IN_MEMORY', // or 'IN_HONOR'
|
|
7198
|
+
tributeName: 'Avraham Cohen',
|
|
7199
|
+
message: 'From the whole family.',
|
|
7200
|
+
returnPath: '/thank-you',
|
|
7201
|
+
});
|
|
7202
|
+
// → { donationId, status: 'PENDING', amount, feeCoverAmount, chargeAmount, currency, payment }
|
|
7203
|
+
|
|
7204
|
+
// 3. Complete donation.payment with the provider — same shape as a checkout intent.
|
|
7205
|
+
|
|
7206
|
+
// 4. ONLY NOW may you thank anyone.
|
|
7207
|
+
const settled = await brainerce.getDonation(donation.donationId);
|
|
7208
|
+
if (settled.status === 'PAID') {
|
|
7209
|
+
show(`Thank you, ${settled.donorName ?? 'friend'}`); // null when anonymous
|
|
7210
|
+
}
|
|
7211
|
+
```
|
|
7212
|
+
|
|
7213
|
+
**⛔ A donation is not a checkout.** No line item, no quantity, no shipping, no order, and it is reported separately from sales. Do not route it through the cart and do not model it as a product — the amount is the tell, because a cart cannot let a donor type one. A "Donation $18" product also files every gift into the merchant's sales figures.
|
|
7214
|
+
|
|
7215
|
+
**⛔ `createDonation` resolving is not a completed gift.** It returns `status: 'PENDING'` and a provider intent; the money has not moved. The gift becomes `PAID` only when the provider's webhook confirms it — the same reason you call `waitForOrder()` after a checkout instead of trusting the payment callback. Do not thank the donor, show the amount as given, or send anything receipt-shaped before `getDonation()` reads `PAID`.
|
|
7216
|
+
|
|
7217
|
+
**The fee cover is charged on top, never taken out.** `amount` is the gift and stays the gift; `feeCoverAmount` is extra; `chargeAmount` is what the card is charged. Between 55% and 60% of donors accept the fee when a form offers it, which makes it the highest-value checkbox on the page.
|
|
7218
|
+
|
|
7219
|
+
**`returnPath` is a path, not a URL.** `/thank-you`, on your own storefront. A full URL is rejected: the payment provider redirects a real browser to this value, so accepting one from the page would be an open redirect.
|
|
7220
|
+
|
|
7221
|
+
**A tribute needs a name.** Setting `tributeType` without `tributeName` is rejected, so make the name required the moment a tribute type is chosen.
|
|
7222
|
+
|
|
7223
|
+
**"Anonymous" is a public-surface flag, not privacy.** It withholds `donorName` from `getDonation()` so the payload is safe to render on a public page. The organisation still sees who gave. Say that on the form — donors read "anonymous" as "untraceable", and it is not.
|
|
7224
|
+
|
|
7225
|
+
**Rate limit:** 5 requests per minute on both calls. Create is capped because an unauthenticated endpoint that mints payment intents is a card-testing instrument; read-back is capped because an id that either resolves or 404s can be enumerated. Poll a handful of times after the donor returns, never on a one-second interval.
|
|
7226
|
+
|
|
7227
|
+
**What it does not do:**
|
|
7228
|
+
|
|
7229
|
+
- **No receipts.** Brainerce records the donation. It does not issue a tax receipt and does not file anything with any tax authority.
|
|
7230
|
+
- **No recurring donations from the storefront.** Standing orders exist, but a donor cannot start one themselves — the merchant arms it from the dashboard for a customer who already saved a card. Do not build a "monthly" toggle that calls `createDonation`: it would quietly produce a one-off gift.
|
|
7231
|
+
- **No funds, campaigns or goal meters.**
|
|
7232
|
+
- **No admin mode.** Both methods throw with an `apiKey` client. A donation is a donor-facing act; use `salesChannelId` or `storeId`.
|
|
7233
|
+
|
|
7234
|
+
---
|
|
7235
|
+
|
|
6764
7236
|
## Storefront Bot (AI chat widget)
|
|
6765
7237
|
|
|
6766
|
-
Add the store's AI shopping assistant with one line. Configuration (name, avatar, colors, greeting, starter questions, guardrails) is normally set in the merchant dashboard
|
|
7238
|
+
Add the store's AI shopping assistant with one line. Configuration (name, avatar, colors, greeting, starter questions, guardrails) is normally set in the merchant dashboard, and the widget renders nothing until the bot is switched Live there. Those same settings are also readable/writable via `client.getBotSettings()` / `client.updateBotSettings()`, and conversation transcripts + summarization via `client.listBotConversations()` / `client.summarizeBotConversation()`. See [Storefront Bot Settings](#storefront-bot-settings) and [Storefront Bot Conversations](#storefront-bot-conversations) in the Admin API Reference.
|
|
6767
7239
|
|
|
6768
7240
|
```html
|
|
6769
7241
|
<!-- zero-code embed: keep the tag exactly this bare (no integrity/crossorigin) -->
|
|
@@ -6791,9 +7263,9 @@ bot?.destroy(); // optional teardown
|
|
|
6791
7263
|
|
|
6792
7264
|
The widget persists an anonymous session in `localStorage`, restores conversations on revisit, streams answers, and shows product recommendation cards (image, price, add-to-cart / view buttons). Multi-variant products get an **in-card variant picker** (attribute chips, live variant price/image) so shoppers can choose options and add without leaving the chat; shoppers can also just ask the assistant to add an item ("add the moka to my cart") and it happens through the same chain. A leave-a-message form lands in the merchant's Inquiries inbox, and zero-result searches feed the merchant's "unmet demand" analytics.
|
|
6793
7265
|
|
|
6794
|
-
**Add to cart resolution** (never a dead button): the widget first calls your `onAddToCart` option; without one it dispatches a cancelable `brainerce:bot:add-to-cart` `CustomEvent` on `window` (`detail: { productId, variantId, quantity, connectionId }
|
|
7266
|
+
**Add to cart resolution** (never a dead button): the widget first calls your `onAddToCart` option; without one it dispatches a cancelable `brainerce:bot:add-to-cart` `CustomEvent` on `window` (`detail: { productId, variantId, quantity, connectionId }`; call `preventDefault()` after handling it); if nothing handles either, it navigates to the product page. Products too complex for in-chat picking (3+ attribute dimensions or 25+ variants) always navigate. Aside from your own cart handler, the widget is read-only by design, and shoppers can never mutate the store through it.
|
|
6795
7267
|
|
|
6796
|
-
**Where the bot is allowed to load.** Every widget call
|
|
7268
|
+
**Where the bot is allowed to load.** Every widget call (bootstrap, chat, escalation) is validated against the page's `Origin` and the domain configured on the connection, the same rule the rest of the storefront API uses. A **Live** connection accepts only its configured domain (exact host or a subdomain) plus any additional allowed origins it lists; a **Test** connection with no domain accepts any origin, which is what makes `localhost` and preview URLs work; a Test connection _with_ a domain behaves like Live. A blocked origin is **not** an error: the bot simply does not render, indistinguishable from "switched off", so nobody can probe which connection ids exist. Mount client-side: server-rendered calls carry no `Origin` and a Live connection refuses them.
|
|
6797
7269
|
|
|
6798
7270
|
Merchant-side display controls (Studio → Storefront Bot): chat size (compact / full screen / shopper's choice), auto-open, position, and whether shoppers may expand the window (`allowExpand`).
|
|
6799
7271
|
|
|
@@ -6838,33 +7310,35 @@ export async function POST(req: Request) {
|
|
|
6838
7310
|
|
|
6839
7311
|
### Webhook Events
|
|
6840
7312
|
|
|
6841
|
-
**These
|
|
7313
|
+
**These 23 event types are what a subscription can actually register.** The
|
|
6842
7314
|
backend validates the `events` array on create against exactly this list, so
|
|
6843
7315
|
anything outside it is rejected rather than silently accepted.
|
|
6844
7316
|
|
|
6845
|
-
| Event | Description
|
|
6846
|
-
| --------------------- |
|
|
6847
|
-
| `order.created` | New order placed (any payment status)
|
|
6848
|
-
| `order.updated` | Order metadata changed (status, address, items)
|
|
6849
|
-
| `order.paid` | Order is paid
|
|
6850
|
-
| `order.fulfilled` | All items marked shipped/delivered
|
|
6851
|
-
| `order.cancelled` | Order cancelled (by merchant or customer)
|
|
6852
|
-
| `order.refunded` | Order fully or partially refunded
|
|
6853
|
-
| `customer.created` | New customer account created
|
|
6854
|
-
| `customer.updated` | Customer profile or contact details changed
|
|
6855
|
-
| `customer.deleted` | Customer account deleted
|
|
6856
|
-
| `product.created` | New product added to catalog
|
|
6857
|
-
| `product.updated` | Product attributes, variants, or pricing changed
|
|
6858
|
-
| `product.deleted` | Product removed from catalog
|
|
6859
|
-
| `inventory.updated` | Stock level changed (any reason)
|
|
6860
|
-
| `inventory.low` | Stock fell below the low-stock threshold
|
|
6861
|
-
| `checkout.completed` | Checkout completed (synonym of `order.created` for now)
|
|
6862
|
-
| `checkout.abandoned` | Cart inactive for 1+ hours with no completion
|
|
6863
|
-
| `payment.succeeded` | Payment provider confirmed funds captured
|
|
6864
|
-
| `payment.failed` | Payment provider rejected the transaction
|
|
6865
|
-
| `payment.refunded` | Refund posted to the customer
|
|
6866
|
-
| `blog.post.published` | Post went live (manual, scheduled, or SEO Autopilot)
|
|
6867
|
-
| `blog.post.updated` | Published post content changed
|
|
7317
|
+
| Event | Description |
|
|
7318
|
+
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
7319
|
+
| `order.created` | New order placed (any payment status) |
|
|
7320
|
+
| `order.updated` | Order metadata changed (status, address, items) |
|
|
7321
|
+
| `order.paid` | Order is paid, by provider capture **or** a merchant-recorded out-of-band payment (cash on delivery, bank transfer). Never assume a provider was involved; `payment.succeeded` does **not** fire for these |
|
|
7322
|
+
| `order.fulfilled` | All items marked shipped/delivered |
|
|
7323
|
+
| `order.cancelled` | Order cancelled (by merchant or customer) |
|
|
7324
|
+
| `order.refunded` | Order fully or partially refunded |
|
|
7325
|
+
| `customer.created` | New customer account created |
|
|
7326
|
+
| `customer.updated` | Customer profile or contact details changed |
|
|
7327
|
+
| `customer.deleted` | Customer account deleted |
|
|
7328
|
+
| `product.created` | New product added to catalog |
|
|
7329
|
+
| `product.updated` | Product attributes, variants, or pricing changed |
|
|
7330
|
+
| `product.deleted` | Product removed from catalog |
|
|
7331
|
+
| `inventory.updated` | Stock level changed (any reason) |
|
|
7332
|
+
| `inventory.low` | Stock fell below the low-stock threshold |
|
|
7333
|
+
| `checkout.completed` | Checkout completed (synonym of `order.created` for now) |
|
|
7334
|
+
| `checkout.abandoned` | Cart inactive for 1+ hours with no completion |
|
|
7335
|
+
| `payment.succeeded` | Payment provider confirmed funds captured |
|
|
7336
|
+
| `payment.failed` | Payment provider rejected the transaction |
|
|
7337
|
+
| `payment.refunded` | Refund posted to the customer |
|
|
7338
|
+
| `blog.post.published` | Post went live (manual, scheduled, or SEO Autopilot) |
|
|
7339
|
+
| `blog.post.updated` | Published post content changed |
|
|
7340
|
+
| `donation.paid` | A donation settled. `createDonation` returns `PENDING` and this is the only signal money actually moved, so a receipting integration waits for it. The payload carries both the donor's intended `amount` and the `chargedAmount` the provider took |
|
|
7341
|
+
| `donation.refunded` | A settled donation was refunded. There is no `REFUNDED` donation status — this event is how you learn |
|
|
6868
7342
|
|
|
6869
7343
|
Payload shapes for each are in the
|
|
6870
7344
|
[Event Catalogue](https://brainerce.com/docs/webhooks/events).
|
|
@@ -6873,27 +7347,14 @@ Payload shapes for each are in the
|
|
|
6873
7347
|
just merchant-created customers, so a storefront that registers customers will
|
|
6874
7348
|
start seeing it.
|
|
6875
7349
|
|
|
6876
|
-
|
|
6877
|
-
|
|
6878
|
-
|
|
6879
|
-
|
|
6880
|
-
|
|
6881
|
-
|
|
6882
|
-
|
|
6883
|
-
|
|
6884
|
-
> `blog.post.published`, `blog.post.updated`. So
|
|
6885
|
-
> `isWebhookEventType(event, 'customer.created')` and a
|
|
6886
|
-
> `createWebhookHandler({ 'order.paid': … })` key **fail to compile**, even
|
|
6887
|
-
> though both deliver correctly at runtime. Cast the name
|
|
6888
|
-
> (`'customer.created' as WebhookEventType`) or read `event.event` as a
|
|
6889
|
-
> `string` and switch on it yourself. Do not conclude the event does not exist.
|
|
6890
|
-
>
|
|
6891
|
-
> **8 names in the type cannot be subscribed to at all:** `coupon.created`,
|
|
6892
|
-
> `coupon.updated`, `coupon.deleted`, `cart.created`, `cart.updated`,
|
|
6893
|
-
> `cart.abandoned`, `checkout.started`, `checkout.failed`. These compile
|
|
6894
|
-
> cleanly and then fail at subscription time. `cart.abandoned` in particular
|
|
6895
|
-
> was listed as a supported event here for a long time — use
|
|
6896
|
-
> `checkout.abandoned` instead.
|
|
7350
|
+
The `WebhookEventType` type matches this table exactly as of SDK 2.1.0 —
|
|
7351
|
+
`isWebhookEventType(event, 'customer.created')` and
|
|
7352
|
+
`createWebhookHandler({ 'order.paid': … })` both compile and match the
|
|
7353
|
+
subscribable set. (Previous SDK versions shipped a stale 15-entry type that
|
|
7354
|
+
was missing 14 real events and still listed 8 fake ones — `coupon.*`,
|
|
7355
|
+
`cart.*`, `checkout.started`, `checkout.failed` — that never existed as
|
|
7356
|
+
subscribable events. If you're on an older SDK version, upgrade rather than
|
|
7357
|
+
casting around the type.)
|
|
6897
7358
|
|
|
6898
7359
|
---
|
|
6899
7360
|
|
|
@@ -6976,6 +7437,7 @@ When building a store, implement these pages:
|
|
|
6976
7437
|
- [ ] **Auth Callback** (`/auth/callback`) - Handle OAuth redirects from Google/Facebook/GitHub
|
|
6977
7438
|
- [ ] **Verify Email** (`/verify-email`) - Email verification with 6-digit code (if store requires it)
|
|
6978
7439
|
- [ ] **Account** (`/account`) - Profile, addresses, and full order history (per-item customizations, shipping & tracking, payment status, status timeline)
|
|
7440
|
+
- [ ] **Donate** (`/donate`) - ONLY when `getStoreInfo().donationsEnabled` is true. One form, outside the cart: preset amounts + a free "other amount", email, optional tribute, anonymous checkbox, "cover the fee" checkbox. Do not build it for a store that has not opened donations — the endpoint rejects every submission
|
|
6979
7441
|
|
|
6980
7442
|
### ⚠️ Payment Page is REQUIRED
|
|
6981
7443
|
|