brainerce 2.0.2 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -49,31 +49,35 @@ yarn add brainerce
49
49
 
50
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
- | Feature | SDK entry point | Mandatory |
53
- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- |
54
- | Product list with search, filter, pagination | `client.getProducts()`, `client.getSearchSuggestions(query)` | ✅ |
55
- | Product detail with variant picker, stock, price | `client.getProductBySlug()` + helpers | ✅ |
56
- | Buyer customization fields (engraving, uploads, select) | `product.customizationFields`, `client.uploadCustomizationFile()` | ✅ |
57
- | Cart (add, update, remove, coupon, totals) | `client.addToCart()`, `getCartTotals(cart)` | ✅ |
58
- | Inventory reservation countdown | Cart expiry timestamp from `client.getCart(cartId)` | ✅ |
59
- | Full checkout end-to-end with payment | `setShippingAddress → selectShippingMethod → getPaymentProviders → pay → handlePaymentSuccess → waitForOrder` | ✅ |
60
- | Order confirmation (clear cart + wait for real order) | `client.handlePaymentSuccess()`, `client.waitForOrder()` | |
61
- | Register + email verification flow | `client.registerCustomer()`, `client.verifyEmail()` | ✅ |
62
- | Login + verification branch | `client.loginCustomer()` | ✅ |
63
- | Forgot / reset password | `client.forgotPassword()`, `client.resetPassword()` | ✅ |
64
- | OAuth sign-in buttons + callback handler | `client.getAvailableOAuthProviders()` | ✅ |
65
- | Account area (profile + order history) | `client.getMyProfile()`, `client.updateMyProfile()`, `client.getMyOrders()` | ✅ |
66
- | Loyalty & rewards (points balance + tiers + redeem) | `client.getLoyaltyStatus()`, `client.getAvailableRewards()`, `client.getRecommendedReward()`, `client.redeemLoyaltyReward(id)`, `client.reportSocialShare()` | conditional |
67
- | Loyalty paid membership (premium subscription) | `client.getMembershipPlans()`, `client.getMySavedPaymentMethods()`, `client.subscribeToMembership(params)`, `client.cancelMembership()` | conditional |
68
- | Embeddable loyalty widget (points + rewards on ANY site) | `client.getLoyaltyWidgetSession()` | conditional |
69
- | Global header: cart count + search autocomplete | `client.smartGetCart()`, `client.getSearchSuggestions(query)` | |
70
- | Discount banners + product badges | `client.getDiscountBanners()`, `client.getProductDiscountBadge(productId)` | ✅ |
71
- | Product reviews on PDP + JSON-LD aggregateRating | `client.listProductReviews(id)`, `client.submitProductReview(id, …)` | ✅ |
72
- | Customer photos on reviews | `client.uploadReviewPhoto(productId, file)`, then `imageKeys` on submit | conditional |
73
- | Site chrome (header + footer + announcement bar) | `client.content.header.get()`, `client.content.footer.get()`, `client.content.announcement.list()` | |
74
- | FAQ page | `client.content.faq.get('main', locale)` | conditional |
75
- | Static pages catch-all (`/pages/[slug]`) | `client.content.page.getBySlug(slug, locale)` | conditional |
76
- | Multi-language + RTL (when i18n enabled) | `client.setLocale()`, `client.getStoreDirection(locale)` | conditional |
52
+ | Feature | SDK entry point | Mandatory |
53
+ | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- |
54
+ | Product list with search, filter, pagination | `client.getProducts()`, `client.getSearchSuggestions(query)` | ✅ |
55
+ | Product detail with variant picker, stock, price | `client.getProductBySlug()` + helpers | ✅ |
56
+ | Buyer customization fields (engraving, uploads, select) | `product.customizationFields`, `client.uploadCustomizationFile()` | ✅ |
57
+ | Cart (add, update, remove, coupon, totals) | `client.addToCart()`, `getCartTotals(cart)` | ✅ |
58
+ | Inventory reservation countdown | Cart expiry timestamp from `client.getCart(cartId)` | ✅ |
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 |
61
+ | Order confirmation (clear cart + wait for real order) | `client.handlePaymentSuccess()`, `client.waitForOrder()` | ✅ |
62
+ | Register + email verification flow | `client.registerCustomer()`, `client.verifyEmail()` | ✅ |
63
+ | Login + verification branch | `client.loginCustomer()` | ✅ |
64
+ | Forgot / reset password | `client.forgotPassword()`, `client.resetPassword()` | ✅ |
65
+ | OAuth sign-in buttons + callback handler | `client.getAvailableOAuthProviders()` | ✅ |
66
+ | Account area (profile + order history) | `client.getMyProfile()`, `client.updateMyProfile()`, `client.getMyOrders()` | |
67
+ | Loyalty & rewards (points balance + tiers + redeem) | `client.getLoyaltyStatus()`, `client.getAvailableRewards()`, `client.getRecommendedReward()`, `client.redeemLoyaltyReward(id)`, `client.reportSocialShare()` | conditional |
68
+ | Loyalty paid membership (premium subscription) | `client.getMembershipPlans()`, `client.getMySavedPaymentMethods()`, `client.subscribeToMembership(params)`, `client.cancelMembership()` | conditional |
69
+ | Embeddable loyalty widget (points + rewards on ANY site) | `client.getLoyaltyWidgetSession()` | conditional |
70
+ | Global header: cart count + search autocomplete | `client.smartGetCart()`, `client.getSearchSuggestions(query)` | ✅ |
71
+ | Discount banners + product badges | `client.getDiscountBanners()`, `client.getProductDiscountBadge(productId)` | ✅ |
72
+ | Product reviews on PDP + JSON-LD aggregateRating | `client.listProductReviews(id)`, `client.submitProductReview(id, …)` | |
73
+ | Customer photos on reviews | `client.uploadReviewPhoto(productId, file)`, then `imageKeys` on submit | conditional |
74
+ | Site chrome (header + footer + announcement bar) | `client.content.header.get()`, `client.content.footer.get()`, `client.content.announcement.list()` | |
75
+ | FAQ page | `client.content.faq.get('main', locale)` | conditional |
76
+ | Static pages catch-all (`/pages/[slug]`) | `client.content.page.getBySlug(slug, locale)` | conditional |
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
 
@@ -88,6 +92,7 @@ Violating any of these causes production incidents or broken orders. Read them b
88
92
  - NEVER hardcode product data, categories, or store copy. Brainerce is the database.
89
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
 
@@ -111,6 +116,32 @@ Violating any of these causes production incidents or broken orders. Read them b
111
116
  - NEVER use the checkout total as the cart total; they diverge (tax, shipping, discounts). Display `checkout.lineItems` on the summary, not `cart.items`.
112
117
  - The reservation timer is a hard guarantee. Display the countdown from the cart and let the SDK handle expiry.
113
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
+ **Administering cards (admin `apiKey` only — see [Gift Cards (administration)](#gift-cards-administration)):**
131
+
132
+ - ⛔ **`issueGiftCard` and `reissueGiftCard` return `plaintextCode` exactly once.** The platform stores only an HMAC of it. No later call, no dashboard screen and no database query can produce it again, so an integrator that logs the response and moves on has destroyed a card that a customer paid for. Persist it or deliver it in the same code path that made the call. The one recovery that exists is retrying the SAME `Idempotency-Key` within 24 hours, which replays the identical body; miss that window and the value is stranded on a card nobody can spend.
133
+ - ⛔ **`reissueGiftCard` is not a resend.** It mints a new code, moves the whole balance onto it, and **revokes the old card** — a printed card in a customer's hand stops working the moment the call returns. Use it when a code is lost, never to "email it again".
134
+ - **A `note` is mandatory** on `issueGiftCard`, `reissueGiftCard` and `adjustGiftCardBalance` (3-500 characters), and is written to the append-only ledger permanently. It is the row a finance review reads a year later, so write the reason, not `"api"`.
135
+ - **There is no delete.** Not one route, not in bulk, not ever — the ledger is append-only and a card can carry a statutory retention life. `setGiftCardStatus(id, 'DISABLED')` is the reversible substitute. `bulkSetGiftCardStatus` takes `ACTIVE` and `DISABLED` only; `REVOKED` is refused there because revoking in bulk would strand balances with no replacement to move them to.
136
+ - **Ask for the least scope you need.** `gift_cards:issue` mints stored value and `gift_cards:adjust` rewrites a balance; neither is implied by `gift_cards:read`. This platform grants them self-serve, where Shopify makes you ask their support for the equivalent — which puts the whole weight on asking for less. ⛔ **Never mint `gift_cards:*` for a read-only integration**: the wildcard matches the resource, not the action, so one string hands a BI sync the power to mint and to rewrite balances.
137
+ - **Liability is per currency.** `getGiftCardLiability()` returns `byCurrency`, because balances in different currencies do not add up. Read the array, never the top-level figure alone, on a store that sells in more than one.
138
+
139
+ ### Donations
140
+
141
+ - 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.
142
+ - 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.
143
+ - 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.
144
+
114
145
  ### Token handling
115
146
 
116
147
  - 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.
@@ -177,6 +208,58 @@ These sequences are non-negotiable. The order of SDK calls matters.
177
208
  ```
178
209
  7. Display `checkout.lineItems` (not `cart.items`) on the order summary.
179
210
 
211
+ If the store has gift cards on, the redemption field goes between step 3 and step 4 — see the flow below.
212
+
213
+ ### Gift card redemption flow
214
+
215
+ 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` mode there is no switch to read — build the field anyway; a code on a store without cards is just refused. (Admin mode does have one: `getGiftCardLiability().enabled`. It gates **issuing**, not redemption, so it is not a reason to hide the field either.) 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.
216
+
217
+ This flow is redemption only. Issuing, re-issuing, adjusting and disabling cards are admin-key operations — see [Gift Cards (administration)](#gift-cards-administration).
218
+
219
+ 1. Offer the field on the checkout page (optionally with a "check balance" affordance):
220
+
221
+ ```ts
222
+ const { balance, currency, usable } = await client.checkGiftCardBalance(code);
223
+ // usable === false for an unknown, disabled OR expired card — all identical, by design.
224
+ // Never render "expired" or "not found"; you do not know which it was.
225
+ ```
226
+
227
+ 2. Apply it. The card is a **tender**, so the total does not move:
228
+
229
+ ```ts
230
+ const { tenderId, amountApplied, providerAmountDue } = await client.applyGiftCard(
231
+ checkoutId,
232
+ code
233
+ );
234
+ // checkout.total is UNCHANGED. providerAmountDue is what the card leaves for the provider.
235
+ ```
236
+
237
+ 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.
238
+
239
+ 3. Re-read the checkout and render from it, never from what you remembered:
240
+
241
+ ```ts
242
+ const checkout = await client.getCheckout(checkoutId);
243
+ checkout.tenders; // [{ tenderId, amountApplied }] — oldest first, survives a reload
244
+ checkout.providerAmountDue; // '0.00' means nothing is owed
245
+ ```
246
+
247
+ Summary order: subtotal → discounts → shipping → tax → **total** → one line per gift card → **amount due**.
248
+
249
+ 4. Removing takes the `tenderId`, never the code — a checkout can carry several cards:
250
+
251
+ ```ts
252
+ const { providerAmountDue } = await client.removeGiftCard(checkoutId, tenderId);
253
+ ```
254
+
255
+ Nothing was ever debited, so the held value goes straight back to the card.
256
+
257
+ 5. Then branch on what is still owed:
258
+ - `providerAmountDue > '0.00'` → the normal payment step. `createPaymentIntent` already nets the cards off; charge the intent's `amount` and never subtract anything yourself.
259
+ - `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.
260
+
261
+ ⛔ 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`.
262
+
180
263
  ### Registration flow
181
264
 
182
265
  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.
@@ -306,33 +389,33 @@ the credential, no customer token needed.
306
389
 
307
390
  The SDK exports these utility functions for common UI tasks:
308
391
 
309
- | Function | Purpose | Example |
310
- | ---------------------------------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
311
- | `formatPrice(amount, { currency?, locale? })` | Format prices for display | `formatPrice("99.99", { currency: 'USD' })` → `$99.99` |
312
- | `getPriceDisplay(amount, currency?, locale?)` | Alias for `formatPrice` | Same as above |
313
- | `getDescriptionContent(product)` | Get product description (HTML or text) | `getDescriptionContent(product)` |
314
- | `isHtmlDescription(product)` | Check if description is HTML | `isHtmlDescription(product)` → `true/false` |
315
- | `getStockStatus(inventory)` | Get human-readable stock status | `getStockStatus(inventory)` → `"In Stock"` |
316
- | `getProductPrice(product)` | Get effective price (handles sales) | `getProductPrice(product)` → `29.99` |
317
- | `getProductPriceInfo(product)` | Get price + sale info + discount % (falls back to `priceMin` when `basePrice=0` on VARIABLE) | `{ price, isOnSale, discountPercent }` |
318
- | `getVariantPrice(variant, basePrice)` | Get variant price with fallback | `getVariantPrice(variant, '29.99')` → `34.99` |
319
- | `getCartTotals(cart, shippingPrice?)` | Calculate cart subtotal/discount/total | `{ subtotal, discount, shipping, total }` |
320
- | `getCartItemName(item)` | Get name from nested cart item (product + variant) | `getCartItemName(item)` → `"Blue T-Shirt - Large"` |
321
- | `getCartItemImage(item)` | Get image URL from cart item | `getCartItemImage(item)` → `"https://..."` |
322
- | `getVariantOptions(variant)` | Get variant attributes as array | `[{ name: "Color", value: "Red" }]` |
323
- | `isCouponApplicableToProduct(coupon, product)` | Check if coupon applies | `isCouponApplicableToProduct(coupon, product)` |
324
- | `isAllowedPaymentUrl(url, options?)` | Validate a payment URL host | `isAllowedPaymentUrl(intent.clientSecret)` → `true` |
325
- | `safePaymentRedirect(url, options?)` | Validate then `window.location.href` | `safePaymentRedirect(intent.clientSecret)` |
326
- | `buildProductJsonLd(product, opts)` | schema.org Product JSON-LD (PDPs only) | See SEO section |
327
- | `buildArticleJsonLd(post, opts)` | schema.org Article JSON-LD for blog posts | See SEO section |
328
- | `buildOrganizationJsonLd(store, opts)` | schema.org Organization for the homepage | See SEO section |
329
- | `buildBreadcrumbJsonLd(items)` | schema.org BreadcrumbList | See SEO section |
330
- | `buildProductFaqJsonLd(product)` | schema.org FAQPage from `product.faq` (null when empty); render the same pairs as visible text | `const faq = buildProductFaqJsonLd(product)` |
331
- | `jsonLdScriptProps(data)` | XSS-safe `<script type="application/ld+json">` props | `<script {...jsonLdScriptProps(data)} />` |
332
- | `getBlogSitemapEntries(client, opts)` | Paginate published posts into sitemap entries | See SEO section |
333
- | `getProductSitemapEntries(client, opts)` | ALL published products into sitemap entries (no 100-item clamp) | See SEO section |
334
- | `getCategorySitemapEntries(client, opts)` | Category tree into sitemap entries | See SEO section |
335
- | `client.resolveSlugRedirect(type, slug)` | Renamed slug → current slug (301 support in not-found paths) | See SEO section |
392
+ | Function | Purpose | Example |
393
+ | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
394
+ | `formatPrice(amount, { currency?, locale? })` | Format prices for display | `formatPrice("99.99", { currency: 'USD' })` → `$99.99` |
395
+ | `getPriceDisplay(amount, currency?, locale?)` | Alias for `formatPrice` | Same as above |
396
+ | `getDescriptionContent(product)` | Get product description (HTML or text) | `getDescriptionContent(product)` |
397
+ | `isHtmlDescription(product)` | Check if description is HTML | `isHtmlDescription(product)` → `true/false` |
398
+ | `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"` |
399
+ | `getProductPrice(product)` | Get effective price (handles sales) | `getProductPrice(product)` → `29.99` |
400
+ | `getProductPriceInfo(product)` | Get price + sale info + discount % (falls back to `priceMin` when `basePrice=0` on VARIABLE) | `{ price, isOnSale, discountPercent }` |
401
+ | `getVariantPrice(variant, basePrice)` | Get variant price with fallback | `getVariantPrice(variant, '29.99')` → `34.99` |
402
+ | `getCartTotals(cart, shippingPrice?)` | Calculate cart subtotal/discount/total | `{ subtotal, discount, shipping, total }` |
403
+ | `getCartItemName(item)` | Get name from nested cart item (product + variant) | `getCartItemName(item)` → `"Blue T-Shirt - Large"` |
404
+ | `getCartItemImage(item)` | Get image URL from cart item | `getCartItemImage(item)` → `"https://..."` |
405
+ | `getVariantOptions(variant)` | Get variant attributes as array | `[{ name: "Color", value: "Red" }]` |
406
+ | `isCouponApplicableToProduct(coupon, product)` | Check if coupon applies | `isCouponApplicableToProduct(coupon, product)` |
407
+ | `isAllowedPaymentUrl(url, options?)` | Validate a payment URL host | `isAllowedPaymentUrl(intent.clientSecret)` → `true` |
408
+ | `safePaymentRedirect(url, options?)` | Validate then `window.location.href` | `safePaymentRedirect(intent.clientSecret)` |
409
+ | `buildProductJsonLd(product, opts)` | schema.org Product JSON-LD (PDPs only) | See SEO section |
410
+ | `buildArticleJsonLd(post, opts)` | schema.org Article JSON-LD for blog posts | See SEO section |
411
+ | `buildOrganizationJsonLd(store, opts)` | schema.org Organization for the homepage | See SEO section |
412
+ | `buildBreadcrumbJsonLd(items)` | schema.org BreadcrumbList | See SEO section |
413
+ | `buildProductFaqJsonLd(product)` | schema.org FAQPage from `product.faq` (null when empty); render the same pairs as visible text | `const faq = buildProductFaqJsonLd(product)` |
414
+ | `jsonLdScriptProps(data)` | XSS-safe `<script type="application/ld+json">` props | `<script {...jsonLdScriptProps(data)} />` |
415
+ | `getBlogSitemapEntries(client, opts)` | Paginate published posts into sitemap entries | See SEO section |
416
+ | `getProductSitemapEntries(client, opts)` | ALL published products into sitemap entries (no 100-item clamp) | See SEO section |
417
+ | `getCategorySitemapEntries(client, opts)` | Category tree into sitemap entries | See SEO section |
418
+ | `client.resolveSlugRedirect(type, slug)` | Renamed slug → current slug (301 support in not-found paths) | See SEO section |
336
419
 
337
420
  ```typescript
338
421
  import {
@@ -352,8 +435,11 @@ const priceText = formatPrice(product.basePrice, { currency: 'USD' }); // "$99.9
352
435
  // Get product description (handles HTML vs plain text)
353
436
  const description = getDescriptionContent(product);
354
437
 
355
- // Get stock status text
356
- const stockText = getStockStatus(product.inventory); // "In Stock", "Low Stock", "Out of Stock"
438
+ // Get stock status text. Pass the merchant's threshold, or it never says
439
+ // "Low Stock": the option defaults to 0, which disables the low-stock state.
440
+ const caps = await client.getStoreCapabilities();
441
+ const lowStockThreshold = caps.connection.lowStockWarning ? caps.connection.lowStockThreshold : 0; // the merchant switched low-stock messaging off
442
+ const stockText = getStockStatus(product.inventory, { lowStockThreshold }); // "In Stock", "Low Stock", "Out of Stock"
357
443
 
358
444
  // Get effective price (handles sale prices automatically)
359
445
  const price = getProductPrice(product); // Returns number: 29.99
@@ -384,8 +470,9 @@ const itemImage = getCartItemImage(cartItem); // "https://..."
384
470
  const { hasPayments, providers } = await client.getPaymentProviders();
385
471
 
386
472
  if (!hasPayments) {
387
- // Show error - payment is not configured
388
- return <div>Payment not configured for this store</div>;
473
+ // NORMAL for a new store, not an error. Build the rest of checkout anyway and
474
+ // scope this notice to the payment step; never ship a disabled Pay button.
475
+ return <div>Payment is not set up for this store yet</div>;
389
476
  }
390
477
 
391
478
  // Show payment forms for available providers
@@ -420,7 +507,7 @@ Products can expose `customizationFields`, the merchant-defined inputs the buyer
420
507
  ```typescript
421
508
  if (product.customizationFields?.length) {
422
509
  // Render a form control per field using field.type (TEXT, SELECT,
423
- // MULTI_SELECT, IMAGE, GALLERY, DATE, ...) — see INTEGRATION.md §2.8
510
+ // MULTI_SELECT, IMAGE, GALLERY, DATE, ...) — see the Core Integration guide §2.8
424
511
  }
425
512
 
426
513
  // For IMAGE / GALLERY fields: upload first
@@ -456,7 +543,7 @@ await client.addToCart(cart.id, {
456
543
  });
457
544
  ```
458
545
 
459
- Money on the wire is **always strings** (`priceDelta: "5.00"`). Validation failures arrive as a structured 400 envelope on `BrainerceError.details` with `code: 'MODIFIER_VALIDATION_FAILED'`; the per-issue list is nested at `details.errors[]`, so from the SDK it reads `err.details.details.errors` (`err.details` is the whole response body). See INTEGRATION-RULES.md "Modifier validation errors" for the full code list.
546
+ 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.
460
547
 
461
548
  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).
462
549
 
@@ -1223,26 +1310,25 @@ export function getCartItemCount(): number {
1223
1310
  return client.getSmartCartItemCount();
1224
1311
  }
1225
1312
 
1226
- // ----- Customer Token Helpers -----
1313
+ // ----- Customer Session -----
1314
+ // The auth token must NEVER live in localStorage: any XSS on the page reads it
1315
+ // and the attacker is that customer until it expires. Hand it to your own
1316
+ // server, which sets an HttpOnly cookie the browser cannot read back.
1227
1317
 
1228
- export function setCustomerToken(token: string | null): void {
1229
- if (token) {
1230
- localStorage.setItem('customerToken', token);
1231
- client.setCustomerToken(token);
1232
- } else {
1233
- localStorage.removeItem('customerToken');
1234
- client.clearCustomerToken();
1235
- }
1318
+ export async function startSession(token: string): Promise<void> {
1319
+ client.setCustomerToken(token);
1320
+ await fetch('/api/auth/session', { method: 'POST', body: JSON.stringify({ token }) });
1236
1321
  }
1237
1322
 
1238
- export function restoreCustomerToken(): string | null {
1239
- const token = localStorage.getItem('customerToken');
1240
- if (token) client.setCustomerToken(token);
1241
- return token;
1323
+ export async function endSession(): Promise<void> {
1324
+ client.clearCustomerToken();
1325
+ await fetch('/api/auth/logout', { method: 'POST' });
1242
1326
  }
1243
1327
 
1244
- export function isLoggedIn(): boolean {
1245
- return !!localStorage.getItem('customerToken');
1328
+ // Session state comes from the server, not from a JS-readable flag.
1329
+ export async function getCurrentCustomer() {
1330
+ const res = await fetch('/api/auth/me');
1331
+ return res.ok ? res.json() : null;
1246
1332
  }
1247
1333
  ```
1248
1334
 
@@ -2387,6 +2473,8 @@ await client.removeCheckoutCoupon(checkoutId);
2387
2473
 
2388
2474
  > **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.
2389
2475
 
2476
+ > **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).
2477
+
2390
2478
  #### Cart Totals
2391
2479
 
2392
2480
  ```typescript
@@ -2977,6 +3065,48 @@ enforcement.
2977
3065
  - `per_option` → each SELECT option has its own price
2978
3066
  - `conditional` → surcharge when NUMBER value meets condition (gt, gte, lt, lte, eq)
2979
3067
 
3068
+ #### Gift Cards (a tender, not a discount)
3069
+
3070
+ 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` integration has no capability probe — build the field unconditionally there. An admin (`apiKey`) client can read `getGiftCardLiability().enabled`, but that switch gates **issuing**, not redemption, so it is not a reason to hide the field. Nothing breaks if the store has no cards: an unusable code is simply refused.
3071
+
3072
+ Issuing and managing cards is a separate, admin-key surface: [Gift Cards (administration)](#gift-cards-administration).
3073
+
3074
+ ```typescript
3075
+ // Optional pre-check before applying. Rate limited (5/min).
3076
+ const { balance, currency, usable } = await client.checkGiftCardBalance(code);
3077
+ // usable === false covers unknown, disabled AND expired — you cannot tell which.
3078
+
3079
+ // Apply. The order total does NOT change; tax stays on the full value.
3080
+ const { tenderId, amountApplied, providerAmountDue } = await client.applyGiftCard(checkoutId, code);
3081
+ // amountApplied: '54.50' — only what the order still owed; the rest stays on the card
3082
+ // providerAmountDue: '150.50' — what the provider will be charged
3083
+
3084
+ // Render from the checkout, not from the response above — the hold is server-side.
3085
+ const checkout = await client.getCheckout(checkoutId);
3086
+ checkout.total; // unchanged by the card
3087
+ checkout.tenders; // [{ tenderId, amountApplied }], oldest first
3088
+ checkout.providerAmountDue; // total − every applied card
3089
+
3090
+ // Remove by tenderId, never by code. Held value goes straight back to the card.
3091
+ await client.removeGiftCard(checkoutId, tenderId);
3092
+ ```
3093
+
3094
+ Summary layout — the card belongs **below** the total, never in the discount block:
3095
+
3096
+ ```
3097
+ Subtotal ₪160.00
3098
+ Discount −₪0.00
3099
+ Shipping ₪20.00
3100
+ Tax ₪25.00
3101
+ Total ₪205.00 ← unchanged; this is what tax was calculated on
3102
+ Gift card −₪54.50 ← its own line, below the total
3103
+ Amount due ₪150.50 ← checkout.providerAmountDue, what the provider is charged
3104
+ ```
3105
+
3106
+ **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.
3107
+
3108
+ **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.
3109
+
2980
3110
  #### Checkout Type Definition
2981
3111
 
2982
3112
  ```typescript
@@ -2993,14 +3123,39 @@ interface Checkout {
2993
3123
  shippingAmount: string;
2994
3124
  taxAmount: string; // "0" in inclusive (VAT) mode — see taxBreakdown.totalTax
2995
3125
  taxBreakdown?: TaxBreakdown | null; // { totalTax, pricesIncludeTax, breakdown[] }
3126
+ // breakdown[] is ONE ROW PER TAX and is often more than one row (Canada charges
3127
+ // GST + PST/QST). Loop it; never read breakdown[0].
2996
3128
  total: string;
2997
3129
  couponCode?: string | null;
3130
+ // Gift cards held against this checkout, oldest first. Read these to re-render
3131
+ // applied cards after a reload — the hold lives on the server, not in your state.
3132
+ tenders?: Array<{ tenderId: string; amountApplied: string }>;
3133
+ // What the payment provider will be charged: `total` minus every gift card.
3134
+ // `total` above is deliberately NOT reduced — a gift card is a means of payment,
3135
+ // not a discount, and tax stays calculated on the full order value.
3136
+ providerAmountDue?: string;
2998
3137
  notes?: string | null; // Order note from setCheckoutCustomer/setShippingAddress
2999
3138
  items: CheckoutLineItem[];
3000
3139
  itemCount: number;
3001
3140
  availableShippingRates?: ShippingRate[];
3002
3141
  }
3003
3142
 
3143
+ // Returned by applyGiftCard. Note what is NOT here: the code, the card id, or
3144
+ // anything about who owns it.
3145
+ interface CheckoutTender {
3146
+ tenderId: string; // pass to removeGiftCard; a checkout can carry several cards
3147
+ amountApplied: string; // capped at what the order still owes
3148
+ providerAmountDue: string; // what the provider will be charged after this card
3149
+ }
3150
+
3151
+ // Returned by checkGiftCardBalance. Identical for an unknown code, a disabled
3152
+ // card and an expired one — by design.
3153
+ interface GiftCardBalance {
3154
+ balance: string; // spendable balance, or '0.00' when the card cannot be used
3155
+ currency: string;
3156
+ usable: boolean;
3157
+ }
3158
+
3004
3159
  type CheckoutStatus = 'PENDING' | 'SHIPPING_SET' | 'PAYMENT_PENDING' | 'COMPLETED' | 'FAILED';
3005
3160
 
3006
3161
  interface ShippingRate {
@@ -3217,9 +3372,11 @@ const { hasPayments, providers, defaultProvider } = await client.getPaymentProvi
3217
3372
  const expressMethods = providers.filter(p => p.isAdditive); // e.g. PayPal
3218
3373
  const primary = defaultProvider && !defaultProvider.isAdditive ? defaultProvider : undefined;
3219
3374
 
3220
- // Build dynamic UI based on available providers
3375
+ // Build dynamic UI based on available providers.
3376
+ // hasPayments:false is NORMAL for a new store, not an error: build the whole checkout
3377
+ // anyway and scope this notice to the payment step. Never ship a disabled Pay button.
3221
3378
  if (!hasPayments) {
3222
- return <div>Payment not configured for this store</div>;
3379
+ return <div>Payment is not set up for this store yet</div>;
3223
3380
  }
3224
3381
 
3225
3382
  const stripeProvider = providers.find(p => p.provider === 'stripe');
@@ -4511,6 +4668,126 @@ console.log(store.language); // 'en', 'he', etc.
4511
4668
 
4512
4669
  ---
4513
4670
 
4671
+ ### Store Capabilities
4672
+
4673
+ 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.
4674
+
4675
+ 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.
4676
+
4677
+ ```typescript
4678
+ const caps = await client.getStoreCapabilities();
4679
+
4680
+ // --- store: identity + language ---
4681
+ caps.store.name; // Parent store name
4682
+ caps.store.channelName; // This channel's display name
4683
+ caps.store.currency; // 'USD', 'ILS', ...
4684
+ caps.store.language; // 'en', 'he', ...
4685
+ caps.store.i18n; // present ONLY when multi-language is enabled
4686
+
4687
+ // --- connection: this channel's settings ---
4688
+ caps.connection.lowStockWarning; // false = show no low-stock treatment at all
4689
+ caps.connection.lowStockThreshold; // units at or below which stock is "low"
4690
+ caps.connection.stockAlertsEnabled; // false = do not render "email me when back"
4691
+ caps.connection.requireBirthday; // true = birthday required on the signup form
4692
+ caps.connection.requireEmailVerification;
4693
+ caps.connection.reservationStrategy; // 'ON_CART' | 'ON_CHECKOUT' | 'ON_PAYMENT'
4694
+ caps.connection.reservationTimeout; // minutes before a reservation expires
4695
+ caps.connection.ordersWriteEnabled;
4696
+ caps.connection.guestCheckoutTracking;
4697
+ caps.connection.sandboxPaymentsEnabled;
4698
+ caps.connection.allowedScopes;
4699
+
4700
+ // --- features: which optional features exist ---
4701
+ caps.features.paymentProviders; // [{ name, provider }] — empty means checkout cannot take money yet
4702
+ caps.features.oauthProviders; // [{ provider, isEnabled }]
4703
+ caps.features.hasShippingZones;
4704
+ caps.features.hasDiscountRules;
4705
+ caps.features.hasCoupons;
4706
+ caps.features.hasDownloadableProducts;
4707
+ caps.features.hasCheckoutCustomFields;
4708
+ caps.features.hasGiftCards; // a per-store switch, not a count — see below
4709
+ caps.features.hasContent;
4710
+ caps.features.hasLoyaltyProgram;
4711
+ caps.features.hasReferralProgram;
4712
+ caps.features.hasBirthdayRewards;
4713
+ caps.features.hasBadges;
4714
+ caps.features.hasPaidMembership;
4715
+ caps.features.hasAiRewardRecommendation;
4716
+ ```
4717
+
4718
+ **`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.
4719
+
4720
+ **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:
4721
+
4722
+ ```typescript
4723
+ const lowStockThreshold = caps.connection.lowStockWarning ? caps.connection.lowStockThreshold : 0; // 0 disables the low-stock state in getStockStatus()
4724
+
4725
+ const stockText = getStockStatus(product.inventory, { lowStockThreshold });
4726
+ ```
4727
+
4728
+ **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:
4729
+
4730
+ ```typescript
4731
+ const caps = await client.getStoreCapabilities().catch(() => null);
4732
+ const lowStockThreshold = caps
4733
+ ? caps.connection.lowStockWarning
4734
+ ? caps.connection.lowStockThreshold
4735
+ : 0
4736
+ : 5; // platform default
4737
+ ```
4738
+
4739
+ The full response type is exported as `StoreCapabilities`:
4740
+
4741
+ ```typescript
4742
+ import type { StoreCapabilities } from 'brainerce';
4743
+ ```
4744
+
4745
+ ---
4746
+
4747
+ ### Donations (start & read back)
4748
+
4749
+ 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.
4750
+
4751
+ ```typescript
4752
+ // Gate first — this page does NOT auto-hide.
4753
+ const store = await brainerce.getStoreInfo();
4754
+ if (!store.donationsEnabled) return null;
4755
+
4756
+ const donation = await brainerce.createDonation({
4757
+ amount: 180, // number, the gift itself — EXCLUDES feeCoverAmount
4758
+ feeCoverAmount: 6.3, // optional, charged ON TOP of the gift
4759
+ donorEmail: 'sarah@example.com', // required
4760
+ donorName: 'Sarah Cohen', // optional
4761
+ isAnonymous: false, // optional — hides the name on PUBLIC surfaces only
4762
+ tributeType: 'IN_MEMORY', // optional: 'IN_HONOR' | 'IN_MEMORY'; needs tributeName
4763
+ tributeName: 'Avraham Cohen',
4764
+ message: 'From the whole family.', // optional, plain text — never render as HTML
4765
+ returnPath: '/thank-you', // optional PATH on your storefront. A full URL is rejected.
4766
+ });
4767
+ // → {
4768
+ // donationId: 'don_…',
4769
+ // status: 'PENDING', // ⛔ NOT a paid gift
4770
+ // amount: '180.00', // the gift
4771
+ // feeCoverAmount: '6.30',
4772
+ // chargeAmount: '186.30', // what the card is actually charged
4773
+ // currency: 'ILS',
4774
+ // payment: { intentId, clientSecret?, clientSdk?, redirectUrl?, providerType }
4775
+ // }
4776
+
4777
+ const settled = await brainerce.getDonation(donation.donationId);
4778
+ // → {
4779
+ // id, status: 'PENDING' | 'PAID' | 'FAILED' | 'CANCELLED',
4780
+ // amount, feeCoverAmount, currency,
4781
+ // donorName, // null when the gift was marked anonymous
4782
+ // tributeType, tributeName,
4783
+ // paidAt // null until PAID
4784
+ // }
4785
+ ```
4786
+
4787
+ 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.
4788
+
4789
+ See [Donation Page](#donation-page) for the full walkthrough and the things it deliberately does not do.
4790
+
4514
4791
  ### Traffic Analytics (built-in, no GA4 needed)
4515
4792
 
4516
4793
  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.
@@ -5033,12 +5310,21 @@ const mobileOnlyZone = await client.createShippingZone({
5033
5310
  const rates = await client.getZoneShippingRates('zone_id');
5034
5311
  await client.createZoneShippingRate('zone_id', {
5035
5312
  name: 'Standard Shipping',
5036
- type: 'flat',
5037
- price: 5.99,
5038
- estimatedDays: '3-5',
5313
+ type: 'FLAT_RATE',
5314
+ rateConfig: { amount: 5.99 },
5315
+ minDeliveryDays: 3,
5316
+ maxDeliveryDays: 5,
5039
5317
  });
5040
5318
  ```
5041
5319
 
5320
+ **`type` is one of `FLAT_RATE`, `FREE`, `WEIGHT_BASED`, `PRICE_BASED`,
5321
+ `LOCAL_PICKUP`** — uppercase, and `FLAT_RATE` rather than `FLAT`. The price
5322
+ goes inside `rateConfig`, whose shape follows `type`: `FLAT_RATE` takes
5323
+ `{ amount }`, the two tiered types take tier arrays, and `FREE` and
5324
+ `LOCAL_PICKUP` take none. Unknown top-level properties are rejected rather
5325
+ than ignored, so a leftover `price` or `estimatedDays` fails the call with 400
5326
+ even though everything else is correct.
5327
+
5042
5328
  ### App Store Shipping (live carrier rates)
5043
5329
 
5044
5330
  Once a merchant installs a shipping app from the Brainerce App Store (EasyPost, Shippo, or any
@@ -5179,6 +5465,58 @@ await client.createTaxRate({
5179
5465
  });
5180
5466
  ```
5181
5467
 
5468
+ #### Two taxes on the same sale (`stackable`)
5469
+
5470
+ By default exactly **one** rate applies to a line: the most specific match wins
5471
+ (postal code beats region beats country) and every other match is discarded.
5472
+ `stackable: true` opts a rate into being **summed** with the other stackable
5473
+ rates that match the same address, all charged on the same pre-tax base. Never
5474
+ tax-on-tax — Quebec stopped compounding QST on GST in 2013.
5475
+
5476
+ Canada is the case you will hit. A country-level GST row plus a province row:
5477
+
5478
+ ```typescript
5479
+ await client.createTaxRate({ name: 'GST', rate: 5, country: 'CA', stackable: true });
5480
+ await client.createTaxRate({
5481
+ name: 'QST',
5482
+ rate: 9.975,
5483
+ country: 'CA',
5484
+ region: 'QC',
5485
+ stackable: true,
5486
+ });
5487
+ // A Quebec checkout is taxed 14.975% and taxBreakdown.breakdown has TWO rows.
5488
+
5489
+ // HST already contains the federal 5%, so it is ONE rate left non-stackable —
5490
+ // it wins alone at 13% and never adds the GST row underneath it.
5491
+ await client.createTaxRate({ name: 'HST', rate: 13, country: 'CA', region: 'ON' });
5492
+ ```
5493
+
5494
+ `stackable` defaults to `false`, so every rate that existed before this field
5495
+ keeps the most-specific-wins behaviour unchanged. Stacking never crosses a tax
5496
+ class either: a class-specific rate **replaces** the Standard rates rather than
5497
+ adding to them, so a class that needs GST plus a reduced QST needs both rows
5498
+ created in that class.
5499
+
5500
+ #### Country presets
5501
+
5502
+ `applyTaxPreset` writes a country's whole rate table in one call, already
5503
+ flagged, instead of thirteen provinces entered by hand:
5504
+
5505
+ ```typescript
5506
+ const presets = await client.getTaxPresets();
5507
+ // [{ key: 'CA', country: 'CA', label: 'Canada — GST, HST, PST, QST', rateCount: 11, … }]
5508
+
5509
+ const { created } = await client.applyTaxPreset('CA'); // created === 11
5510
+ ```
5511
+
5512
+ It writes federal GST 5% country-wide, one combined HST row for ON/NB/NL/NS/PE,
5513
+ and PST/RST/QST for BC/SK/MB/QC on top of the GST. Alberta and the territories
5514
+ need no row. It throws **409** when the store already has rates for that
5515
+ country — delete those first if you meant to replace them — so a double call
5516
+ cannot double every province. Rates land in the Standard tax class.
5517
+
5518
+ Brainerce does not register the store for GST/HST and does not file returns.
5519
+
5182
5520
  ### Tax Classes
5183
5521
 
5184
5522
  Tax classes let you charge different rates for different product types (e.g.
@@ -5439,20 +5777,27 @@ Each store has its own team with roles (`OWNER`, `MANAGER`, `STAFF`, `VIEWER`) a
5439
5777
  granular permissions, including per-sales-channel scoping. **Managing it is a dashboard
5440
5778
  operation. There is no SDK path to it, and this is deliberate.**
5441
5779
 
5442
- This section previously showed nine store-level team calls. They do not work:
5443
- `getStoreTeam`, `inviteStoreMember`, `updateStoreMember`, `updateStoreMemberSalesChannels`,
5444
- `removeStoreMember`, `resendStoreInvitation` and `revokeStoreInvitation` call
5445
- `/api/v1/stores/:storeId/team…`, and `getMyStores` / `getMyStorePermissions` call
5446
- `/api/v1/me/stores…`. The public API exposes neither prefix, so every one of them returns
5447
- **404**.
5780
+ This section previously showed store-level team calls. **Since SDK 2.1.1 they throw a
5781
+ `BrainerceError` that says so, instead of returning a bare 404.** Ten methods are
5782
+ affected: `getStoreTeam`, `inviteStoreMember`, `updateStoreMember`,
5783
+ `updateStoreMemberSalesChannels`, `removeStoreMember`, `resendStoreInvitation`,
5784
+ `revokeStoreInvitation`, `acceptStoreInvitation`, `getMyStores` and
5785
+ `getMyStorePermissions`.
5448
5786
 
5449
5787
  **This is not a path typo waiting on a fix.** The real endpoints exist at
5450
5788
  `/api/stores/:storeId/team…`, guarded by `DashboardOnlyGuard`, which rejects API-key
5451
5789
  principals _by design_: an API key carries a `storeId` but never a `userId`, and the
5452
5790
  team service resolves a missing `userId` to an OWNER role. Failing closed at the boundary
5453
5791
  is what stops an API key from escalating its own team permissions. Pointing the SDK at
5454
- the correct path would earn a `403` instead of a `404`. Invite, re-scope and remove
5455
- members in the dashboard.
5792
+ the correct path would earn a `403` instead of a `404` which is why the SDK now raises
5793
+ that `403` locally rather than sending a request that cannot succeed. Invite, re-scope
5794
+ and remove members in the dashboard.
5795
+
5796
+ `getStoreInvitationByToken(token)` is the **one exception and it works**: the lookup
5797
+ route is `@Public()`, and it was only ever failing because the SDK asked for
5798
+ `/api/v1/store-invitations/…` instead of `/api/store-invitations/…`. That path is fixed.
5799
+ Use it to render an invitation-acceptance page; the acceptance itself must happen in the
5800
+ dashboard, because it is matched against the invited user's own email address.
5456
5801
 
5457
5802
  > **The older account-level methods are not a substitute for this.** `getTeamMembers`,
5458
5803
  > `getTeamInvitations`, `inviteTeamMember`, `resendTeamInvitation`, `revokeTeamInvitation`,
@@ -5739,6 +6084,288 @@ const { queued } = await client.aiTranslateBulk(storeId, {
5739
6084
 
5740
6085
  ---
5741
6086
 
6087
+ ### Gift Cards (administration)
6088
+
6089
+ Eight admin-key methods that issue, read, re-issue, adjust and disable stored
6090
+ value. This is the merchant half of the feature; the storefront half — apply,
6091
+ remove, check a balance — is [Gift Cards (a tender, not a discount)](#gift-cards-a-tender-not-a-discount)
6092
+ and needs no API key.
6093
+
6094
+ Every call reaches the same service the dashboard runs, so the rules below hold
6095
+ whether a person or a key is acting.
6096
+
6097
+ | Method | Route | Scope |
6098
+ | --------------------------------------------------- | --------------------------------- | ------------------ |
6099
+ | `listGiftCards(params?)` | `GET /v1/gift-cards` | `gift_cards:read` |
6100
+ | `getGiftCardLiability()` | `GET /v1/gift-cards/liability` | `gift_cards:read` |
6101
+ | `getGiftCard(giftCardId)` | `GET /v1/gift-cards/{id}` | `gift_cards:read` |
6102
+ | `issueGiftCard(data)` | `POST /v1/gift-cards` | `gift_cards:issue` |
6103
+ | `reissueGiftCard(giftCardId, note)` | `POST /v1/gift-cards/{id}/reissue` | `gift_cards:issue` |
6104
+ | `adjustGiftCardBalance(giftCardId, delta, note)` | `PATCH /v1/gift-cards/{id}/adjust` | `gift_cards:adjust` |
6105
+ | `setGiftCardStatus(giftCardId, status)` | `PATCH /v1/gift-cards/{id}/status` | `gift_cards:write` |
6106
+ | `bulkSetGiftCardStatus(giftCardIds, status)` | `PATCH /v1/gift-cards/bulk/status` | `gift_cards:write` |
6107
+
6108
+ #### ⛔ The code is returned exactly once
6109
+
6110
+ `issueGiftCard` and `reissueGiftCard` are the only two moments `plaintextCode`
6111
+ exists in readable form anywhere. The platform keeps an HMAC of it and nothing
6112
+ else, so **no API call, no dashboard screen and no database query can produce it
6113
+ again**. An integration that discards the response has destroyed a card that is
6114
+ still on the books as a liability.
6115
+
6116
+ ```typescript
6117
+ import type { IssuedGiftCardAdmin } from 'brainerce';
6118
+
6119
+ const card: IssuedGiftCardAdmin = await admin.issueGiftCard({
6120
+ amount: '200.00', // decimal STRING, greater than zero
6121
+ note: 'Goodwill for order ORD-20260902-0041, damaged in transit',
6122
+ recipientEmail: 'dana@example.com',
6123
+ });
6124
+
6125
+ await deliverToCustomer(card.plaintextCode); // ← your only chance
6126
+ // card.giftCardId, card.last4 are safe to store. card.plaintextCode is not:
6127
+ // never log it, never persist it in plaintext, never put it in an error message.
6128
+ ```
6129
+
6130
+ The **one** recovery that exists is an idempotent retry. Send an
6131
+ `Idempotency-Key` header on the issue call and re-sending the identical request
6132
+ with the same key inside 24 hours replays the whole cached response — code
6133
+ included — with `X-Idempotent-Replayed: true` on it. Outside that window there is
6134
+ nothing to replay, and the value sits on a card nobody can spend. Send the key.
6135
+
6136
+ #### Reading cards
6137
+
6138
+ ```typescript
6139
+ import type { GiftCardAdmin, GiftCardAdminDetail, GiftCardLiability } from 'brainerce';
6140
+
6141
+ const { data, meta } = await admin.listGiftCards({
6142
+ page: 1,
6143
+ limit: 50, // hard cap 100
6144
+ filter: 'withBalance', // 'all' | 'active' | 'withBalance' | 'expired' | 'disabled'
6145
+ search: 'V2D3', // last FOUR of a code, or part of a recipient email
6146
+ });
6147
+
6148
+ const card: GiftCardAdminDetail = await admin.getGiftCard(data[0].id);
6149
+ card.transactions; // the full append-only ledger, newest first
6150
+ ```
6151
+
6152
+ **`search` cannot match a full code.** Only an HMAC is stored, so there is
6153
+ nothing to search against; the last four characters and the recipient email are
6154
+ the whole of it. That is also what a merchant reads off a support email.
6155
+
6156
+ Two filters behave in ways worth knowing before you build a UI on them:
6157
+
6158
+ - `disabled` returns `DISABLED` **and** `REVOKED` cards, not just disabled ones.
6159
+ - `expired` matches on `expiresAt` being in the past regardless of status, and
6160
+ expiry is derived at read time rather than stamped by a job — so a card that
6161
+ lapsed a second ago is already in this filter, and `expired: true` on the row
6162
+ is true the moment validity ends.
6163
+
6164
+ An expired card's balance is deliberately **not** zeroed, and `expiredAt` is
6165
+ stamped only if an expiry was ever processed.
6166
+
6167
+ ```typescript
6168
+ const liability: GiftCardLiability = await admin.getGiftCardLiability();
6169
+ liability.byCurrency; // [{ currency: 'ILS', active, held, expiredNotWrittenOff }, ...]
6170
+ ```
6171
+
6172
+ This is the month-end close figure. **Read `byCurrency`** if the store sells in
6173
+ more than one: balances in different currencies do not add up, so the top-level
6174
+ `active` / `held` / `expiredNotWrittenOff` cover one currency only and are not a
6175
+ total. `expiredNotWrittenOff` is reported apart from both sides on purpose —
6176
+ whether expiry extinguishes the obligation is an open legal question, so the
6177
+ platform never folds it into either.
6178
+
6179
+ `liability.enabled` is the one admin-mode capability probe for this feature. It
6180
+ says whether the store may **issue**; redemption is not gated on it.
6181
+
6182
+ #### ⛔ Re-issue is not a resend
6183
+
6184
+ `reissueGiftCard` is the answer to a customer who lost their code. It mints a new
6185
+ code, moves the **whole** balance onto it, and **revokes the old card**:
6186
+
6187
+ ```typescript
6188
+ import type { ReissuedGiftCardAdmin } from 'brainerce';
6189
+
6190
+ const replacement: ReissuedGiftCardAdmin = await admin.reissueGiftCard(
6191
+ giftCardId,
6192
+ 'Customer lost the original code — support ticket 8812'
6193
+ );
6194
+ replacement.plaintextCode; // once, same rules as issuance
6195
+ replacement.movedAmount; // what came across from the revoked card
6196
+ replacement.deliveredTo; // email it was sent to, or null → you must hand it over
6197
+ ```
6198
+
6199
+ The old code stops working the moment this returns. If the customer is still
6200
+ holding a printed card, it is now worthless — so do not reach for this when
6201
+ someone simply wants the email again.
6202
+
6203
+ Three things it deliberately does:
6204
+
6205
+ - **Refuses while a checkout holds value on the card.** A live hold means a
6206
+ shopper is mid-payment against it; moving the balance out from under them would
6207
+ strand a provider charge already in flight. Holds are short, so waiting is the
6208
+ correct advice rather than a workaround.
6209
+ - **Carries the ORIGINAL expiry forward.** Re-issue cannot be used to restart an
6210
+ expiry clock, because the clock carries statutory notice duties.
6211
+ - **Works even when the store has gift cards switched off.** Unlike issuance it
6212
+ is not gated on that switch: it moves value that already exists, total
6213
+ liability is identical before and after, and a store that turned the feature
6214
+ off still owes every card already in a customer's hand.
6215
+
6216
+ It also refuses a card that is already `REVOKED` ("re-issue the replacement
6217
+ instead"), one with no balance left to move, and one that changed underneath the
6218
+ call. Those messages are explanatory, unlike the storefront's deliberately
6219
+ uniform refusals — surface them to the merchant.
6220
+
6221
+ #### Adjusting a balance
6222
+
6223
+ ```typescript
6224
+ const { balanceAfter } = await admin.adjustGiftCardBalance(
6225
+ giftCardId,
6226
+ '-25.00', // SIGNED decimal string: '25.00' credits, '-25.00' debits. Never zero
6227
+ 'Chargeback on the original order'
6228
+ );
6229
+ ```
6230
+
6231
+ A debit cannot take the balance below what live checkout holds have already
6232
+ reserved. That refusal names the held amount, so it is actionable: wait for the
6233
+ checkout to complete or be released, then retry.
6234
+
6235
+ #### Status, and the absence of delete
6236
+
6237
+ ```typescript
6238
+ await admin.setGiftCardStatus(giftCardId, 'DISABLED'); // 'ACTIVE' | 'DISABLED' | 'REVOKED'
6239
+ const { updated } = await admin.bulkSetGiftCardStatus(ids, 'DISABLED'); // ACTIVE | DISABLED only
6240
+ ```
6241
+
6242
+ ⛔ **There is no delete — not one route, not in bulk, not ever.** The ledger is
6243
+ append-only and a card may carry a statutory retention life. Disabling is the
6244
+ reversible substitute, and it is what "off" actually means here: it stops **new**
6245
+ holds. It deliberately does not touch a hold a checkout is already carrying,
6246
+ because pulling value out from under a shopper mid-payment would strand a
6247
+ provider charge that has already left.
6248
+
6249
+ `bulkSetGiftCardStatus` takes `ACTIVE` and `DISABLED` only. **`REVOKED` is
6250
+ refused in bulk**: revoking belongs to re-issue, which moves the balance to a
6251
+ replacement first, and revoking a page of cards would strand every balance on
6252
+ them with nowhere to go. Cards already revoked are skipped rather than
6253
+ reactivated — a revoked code put back into circulation beside its replacement
6254
+ would make the same value spendable twice — so `updated` is the honest count and
6255
+ can be lower than the number of ids you sent. Maximum **200 ids** per call.
6256
+
6257
+ #### Scopes are separable on purpose
6258
+
6259
+ `gift_cards:issue` mints stored value. `gift_cards:adjust` rewrites a balance.
6260
+ Neither is implied by `gift_cards:read`, and that separation is the whole control
6261
+ surface here: Shopify makes a merchant contact their support for the equivalent
6262
+ permission, while this platform grants it self-serve. Ask for the least your
6263
+ integration needs.
6264
+
6265
+ ⛔ **`gift_cards:*` is not "gift cards, read-only".** The wildcard matches the
6266
+ **resource**, so one string grants `read`, `write`, `issue` **and** `adjust`
6267
+ together. A reporting or BI integration that asks for it has been handed the
6268
+ power to mint money and to rewrite balances. Enumerate the scopes instead.
6269
+
6270
+ Note the underscore: the scope is `gift_cards:read`, not `gift-cards:read`. A key
6271
+ minted with the hyphen matches nothing and every call 403s with
6272
+ `INSUFFICIENT_SCOPE`, which reads like a permissions bug rather than a typo.
6273
+
6274
+ #### Rules that hold on every call
6275
+
6276
+ - **A `note` is mandatory** on issue, re-issue and adjust — 3 to 500 characters —
6277
+ and is written to the append-only ledger permanently. It is the row a finance
6278
+ review reads a year from now, so write the reason, not `"api"`.
6279
+ - **Money is decimal strings, never numbers.** `amount`, `delta`, every balance.
6280
+ A JSON number is a float, and the one thing that must not happen to stored
6281
+ value is arriving a cent short because it went through a double on the way in.
6282
+ - **There is no `currency` parameter on issue.** A card is always minted in the
6283
+ store's own currency, and a card pays only in its own currency with no
6284
+ conversion — so this API cannot issue a EUR card on an ILS store to compensate
6285
+ a foreign-currency customer. Neither half of that is configurable.
6286
+ - **Issuing is refused while gift cards are switched off for the store**, with a
6287
+ 400 that says so. The feature is off by default. Re-issue, adjust and status
6288
+ are not gated on it.
6289
+ - **All five write routes accept `Idempotency-Key`.** Send it on issue and
6290
+ re-issue especially: without one, a retried timeout mints a *second* card and a
6291
+ real liability nobody asked for.
6292
+
6293
+ #### Type definitions
6294
+
6295
+ ```typescript
6296
+ interface GiftCardAdmin {
6297
+ id: string;
6298
+ maskedCode: string; // '••••-••••-••••-••••-V2D3'
6299
+ codeLast4: string;
6300
+ initialAmount: string; // decimal strings, all of them
6301
+ balance: string; // settled value on the card
6302
+ heldAmount: string; // reserved by a checkout in progress, not available to spend
6303
+ spendable: string; // balance - heldAmount
6304
+ currency: string;
6305
+ status: 'ACTIVE' | 'DISABLED' | 'REVOKED';
6306
+ customerId: string | null;
6307
+ recipientEmail: string | null;
6308
+ expiresAt: string | null;
6309
+ expiredAt: string | null; // stamped if an expiry was processed; balance is NOT zeroed
6310
+ expired: boolean; // derived at read time
6311
+ createdAt: string;
6312
+ }
6313
+
6314
+ interface GiftCardTransaction {
6315
+ id: string;
6316
+ type: 'ISSUE' | 'REDEEM' | 'REFUND' | 'ADJUST' | 'EXPIRE';
6317
+ amount: string; // signed; negative debits the card
6318
+ balanceAfter: string;
6319
+ orderId: string | null;
6320
+ actorUserId: string | null;
6321
+ note: string | null;
6322
+ createdAt: string;
6323
+ }
6324
+
6325
+ interface GiftCardAdminDetail extends GiftCardAdmin {
6326
+ recipientName: string | null;
6327
+ orderId: string | null;
6328
+ transactions: GiftCardTransaction[]; // append-only; nothing here is ever rewritten
6329
+ }
6330
+
6331
+ interface GiftCardLiability {
6332
+ active: string;
6333
+ held: string;
6334
+ expiredNotWrittenOff: string;
6335
+ currency: string | null;
6336
+ byCurrency: Array<{
6337
+ currency: string;
6338
+ active: string;
6339
+ held: string;
6340
+ expiredNotWrittenOff: string;
6341
+ }>;
6342
+ enabled: boolean; // may the store ISSUE? Redemption is not gated on this
6343
+ }
6344
+
6345
+ interface IssuedGiftCardAdmin {
6346
+ giftCardId: string;
6347
+ plaintextCode: string; // ⚠️ returned EXACTLY ONCE
6348
+ last4: string;
6349
+ }
6350
+
6351
+ interface ReissuedGiftCardAdmin extends IssuedGiftCardAdmin {
6352
+ movedAmount: string;
6353
+ deliveredTo: string | null; // null → the merchant must hand the code over
6354
+ }
6355
+
6356
+ interface IssueGiftCardAdminDto {
6357
+ amount: string; // decimal string, greater than zero
6358
+ note: string; // REQUIRED, 3-500 chars, permanent on the ledger
6359
+ customerId?: string;
6360
+ expiresAt?: string; // ISO 8601, must be in the future. Omit for no expiry
6361
+ recipientEmail?: string;
6362
+ recipientName?: string;
6363
+ personalMessage?: string;
6364
+ }
6365
+ ```
6366
+
6367
+ ---
6368
+
5742
6369
  ## Complete Page Examples
5743
6370
 
5744
6371
  ### Home Page
@@ -6289,6 +6916,7 @@ export default function CheckoutPage() {
6289
6916
  > - 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.
6290
6917
  > - `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.
6291
6918
  > - Guest session cart is created automatically by `smart*` methods
6919
+ > - 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)
6292
6920
  > - Call `client.onCheckoutComplete()` after successful payment to clear the session cart
6293
6921
  > - Call `client.syncCartOnLogin()` when a user logs in to merge their guest cart
6294
6922
 
@@ -6852,6 +7480,61 @@ The merchant reads the demand at `Products → Back-in-Stock Waitlist`: products
6852
7480
 
6853
7481
  ---
6854
7482
 
7483
+ ## Donation Page
7484
+
7485
+ **SDK >= 2.1.** A donation page, for a store that takes gifts as well as — or instead of — selling things.
7486
+
7487
+ ```typescript
7488
+ // 1. Gate the page. This one does not auto-hide.
7489
+ const store = await brainerce.getStoreInfo();
7490
+ if (!store.donationsEnabled) return null;
7491
+
7492
+ // 2. Start the donation.
7493
+ const donation = await brainerce.createDonation({
7494
+ amount: 180, // the gift
7495
+ feeCoverAmount: 6.3, // only when the donor ticked "cover the fee"
7496
+ donorEmail: 'sarah@example.com',
7497
+ donorName: 'Sarah Cohen',
7498
+ isAnonymous: false,
7499
+ tributeType: 'IN_MEMORY', // or 'IN_HONOR'
7500
+ tributeName: 'Avraham Cohen',
7501
+ message: 'From the whole family.',
7502
+ returnPath: '/thank-you',
7503
+ });
7504
+ // → { donationId, status: 'PENDING', amount, feeCoverAmount, chargeAmount, currency, payment }
7505
+
7506
+ // 3. Complete donation.payment with the provider — same shape as a checkout intent.
7507
+
7508
+ // 4. ONLY NOW may you thank anyone.
7509
+ const settled = await brainerce.getDonation(donation.donationId);
7510
+ if (settled.status === 'PAID') {
7511
+ show(`Thank you, ${settled.donorName ?? 'friend'}`); // null when anonymous
7512
+ }
7513
+ ```
7514
+
7515
+ **⛔ 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.
7516
+
7517
+ **⛔ `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`.
7518
+
7519
+ **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.
7520
+
7521
+ **`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.
7522
+
7523
+ **A tribute needs a name.** Setting `tributeType` without `tributeName` is rejected, so make the name required the moment a tribute type is chosen.
7524
+
7525
+ **"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.
7526
+
7527
+ **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.
7528
+
7529
+ **What it does not do:**
7530
+
7531
+ - **No receipts.** Brainerce records the donation. It does not issue a tax receipt and does not file anything with any tax authority.
7532
+ - **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.
7533
+ - **No funds, campaigns or goal meters.**
7534
+ - **No admin mode.** Both methods throw with an `apiKey` client. A donation is a donor-facing act; use `salesChannelId` or `storeId`.
7535
+
7536
+ ---
7537
+
6855
7538
  ## Storefront Bot (AI chat widget)
6856
7539
 
6857
7540
  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.
@@ -6929,33 +7612,35 @@ export async function POST(req: Request) {
6929
7612
 
6930
7613
  ### Webhook Events
6931
7614
 
6932
- **These 21 event types are what a subscription can actually register.** The
7615
+ **These 23 event types are what a subscription can actually register.** The
6933
7616
  backend validates the `events` array on create against exactly this list, so
6934
7617
  anything outside it is rejected rather than silently accepted.
6935
7618
 
6936
- | Event | Description |
6937
- | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
6938
- | `order.created` | New order placed (any payment status) |
6939
- | `order.updated` | Order metadata changed (status, address, items) |
6940
- | `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 |
6941
- | `order.fulfilled` | All items marked shipped/delivered |
6942
- | `order.cancelled` | Order cancelled (by merchant or customer) |
6943
- | `order.refunded` | Order fully or partially refunded |
6944
- | `customer.created` | New customer account created |
6945
- | `customer.updated` | Customer profile or contact details changed |
6946
- | `customer.deleted` | Customer account deleted |
6947
- | `product.created` | New product added to catalog |
6948
- | `product.updated` | Product attributes, variants, or pricing changed |
6949
- | `product.deleted` | Product removed from catalog |
6950
- | `inventory.updated` | Stock level changed (any reason) |
6951
- | `inventory.low` | Stock fell below the low-stock threshold |
6952
- | `checkout.completed` | Checkout completed (synonym of `order.created` for now) |
6953
- | `checkout.abandoned` | Cart inactive for 1+ hours with no completion |
6954
- | `payment.succeeded` | Payment provider confirmed funds captured |
6955
- | `payment.failed` | Payment provider rejected the transaction |
6956
- | `payment.refunded` | Refund posted to the customer |
6957
- | `blog.post.published` | Post went live (manual, scheduled, or SEO Autopilot) |
6958
- | `blog.post.updated` | Published post content changed |
7619
+ | Event | Description |
7620
+ | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
7621
+ | `order.created` | New order placed (any payment status) |
7622
+ | `order.updated` | Order metadata changed (status, address, items) |
7623
+ | `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 |
7624
+ | `order.fulfilled` | All items marked shipped/delivered |
7625
+ | `order.cancelled` | Order cancelled (by merchant or customer) |
7626
+ | `order.refunded` | Order fully or partially refunded |
7627
+ | `customer.created` | New customer account created |
7628
+ | `customer.updated` | Customer profile or contact details changed |
7629
+ | `customer.deleted` | Customer account deleted |
7630
+ | `product.created` | New product added to catalog |
7631
+ | `product.updated` | Product attributes, variants, or pricing changed |
7632
+ | `product.deleted` | Product removed from catalog |
7633
+ | `inventory.updated` | Stock level changed (any reason) |
7634
+ | `inventory.low` | Stock fell below the low-stock threshold |
7635
+ | `checkout.completed` | Checkout completed (synonym of `order.created` for now) |
7636
+ | `checkout.abandoned` | Cart inactive for 1+ hours with no completion |
7637
+ | `payment.succeeded` | Payment provider confirmed funds captured |
7638
+ | `payment.failed` | Payment provider rejected the transaction |
7639
+ | `payment.refunded` | Refund posted to the customer |
7640
+ | `blog.post.published` | Post went live (manual, scheduled, or SEO Autopilot) |
7641
+ | `blog.post.updated` | Published post content changed |
7642
+ | `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 |
7643
+ | `donation.refunded` | A settled donation was refunded. There is no `REFUNDED` donation status — this event is how you learn |
6959
7644
 
6960
7645
  Payload shapes for each are in the
6961
7646
  [Event Catalogue](https://brainerce.com/docs/webhooks/events).
@@ -6964,7 +7649,7 @@ Payload shapes for each are in the
6964
7649
  just merchant-created customers, so a storefront that registers customers will
6965
7650
  start seeing it.
6966
7651
 
6967
- The `WebhookEventType` type matches this table exactly as of SDK 2.0.2
7652
+ The `WebhookEventType` type matches this table exactly as of SDK 2.1.0 —
6968
7653
  `isWebhookEventType(event, 'customer.created')` and
6969
7654
  `createWebhookHandler({ 'order.paid': … })` both compile and match the
6970
7655
  subscribable set. (Previous SDK versions shipped a stale 15-entry type that
@@ -7054,6 +7739,7 @@ When building a store, implement these pages:
7054
7739
  - [ ] **Auth Callback** (`/auth/callback`) - Handle OAuth redirects from Google/Facebook/GitHub
7055
7740
  - [ ] **Verify Email** (`/verify-email`) - Email verification with 6-digit code (if store requires it)
7056
7741
  - [ ] **Account** (`/account`) - Profile, addresses, and full order history (per-item customizations, shipping & tracking, payment status, status timeline)
7742
+ - [ ] **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
7057
7743
 
7058
7744
  ### ⚠️ Payment Page is REQUIRED
7059
7745
 
@@ -7213,8 +7899,8 @@ const handlePlaceOrder = () => {
7213
7899
  - Import types from the SDK
7214
7900
  - Handle loading states and errors
7215
7901
  - **Use toast notifications (Sonner) for user feedback on actions**
7216
- - Persist cart ID in localStorage
7217
- - Persist customer token after login
7902
+ - **Let the SDK persist the cart** it keeps the cart reference in `brainerce_session` itself. Do not write your own cart-to-localStorage code; a second copy drifts out of sync with the server
7903
+ - **NEVER put the customer token in `localStorage`** — any XSS reads it and the attacker is that customer until it expires. Hold it in memory with `setCustomerToken()`, or front the SDK with a BFF proxy (`proxyMode: true`) that keeps it in an HttpOnly cookie
7218
7904
  - **Use `isHtmlDescription(product)` helper and render HTML with `dangerouslySetInnerHTML` when it returns true**
7219
7905
  - **Wrap SDK calls in try/catch and show error toasts**
7220
7906