brainerce 2.0.2 → 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 CHANGED
@@ -57,6 +57,7 @@ Every Brainerce storefront must include **all mandatory features** below. Featur
57
57
  | Cart (add, update, remove, coupon, totals) | `client.addToCart()`, `getCartTotals(cart)` | ✅ |
58
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()` | ✅ |
@@ -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
 
@@ -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,23 @@ 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
+ ### 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.
135
+
114
136
  ### Token handling
115
137
 
116
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.
@@ -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.
@@ -306,33 +378,33 @@ the credential, no customer token needed.
306
378
 
307
379
  The SDK exports these utility functions for common UI tasks:
308
380
 
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 |
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 |
336
408
 
337
409
  ```typescript
338
410
  import {
@@ -352,8 +424,11 @@ const priceText = formatPrice(product.basePrice, { currency: 'USD' }); // "$99.9
352
424
  // Get product description (handles HTML vs plain text)
353
425
  const description = getDescriptionContent(product);
354
426
 
355
- // Get stock status text
356
- const stockText = getStockStatus(product.inventory); // "In Stock", "Low Stock", "Out of Stock"
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"
357
432
 
358
433
  // Get effective price (handles sale prices automatically)
359
434
  const price = getProductPrice(product); // Returns number: 29.99
@@ -384,8 +459,9 @@ const itemImage = getCartItemImage(cartItem); // "https://..."
384
459
  const { hasPayments, providers } = await client.getPaymentProviders();
385
460
 
386
461
  if (!hasPayments) {
387
- // Show error - payment is not configured
388
- return <div>Payment not configured for this store</div>;
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>;
389
465
  }
390
466
 
391
467
  // Show payment forms for available providers
@@ -420,7 +496,7 @@ Products can expose `customizationFields`, the merchant-defined inputs the buyer
420
496
  ```typescript
421
497
  if (product.customizationFields?.length) {
422
498
  // Render a form control per field using field.type (TEXT, SELECT,
423
- // MULTI_SELECT, IMAGE, GALLERY, DATE, ...) — see INTEGRATION.md §2.8
499
+ // MULTI_SELECT, IMAGE, GALLERY, DATE, ...) — see the Core Integration guide §2.8
424
500
  }
425
501
 
426
502
  // For IMAGE / GALLERY fields: upload first
@@ -456,7 +532,7 @@ await client.addToCart(cart.id, {
456
532
  });
457
533
  ```
458
534
 
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.
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.
460
536
 
461
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).
462
538
 
@@ -1223,26 +1299,25 @@ export function getCartItemCount(): number {
1223
1299
  return client.getSmartCartItemCount();
1224
1300
  }
1225
1301
 
1226
- // ----- Customer Token Helpers -----
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.
1227
1306
 
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
- }
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 }) });
1236
1310
  }
1237
1311
 
1238
- export function restoreCustomerToken(): string | null {
1239
- const token = localStorage.getItem('customerToken');
1240
- if (token) client.setCustomerToken(token);
1241
- return token;
1312
+ export async function endSession(): Promise<void> {
1313
+ client.clearCustomerToken();
1314
+ await fetch('/api/auth/logout', { method: 'POST' });
1242
1315
  }
1243
1316
 
1244
- export function isLoggedIn(): boolean {
1245
- return !!localStorage.getItem('customerToken');
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;
1246
1321
  }
1247
1322
  ```
1248
1323
 
@@ -2387,6 +2462,8 @@ await client.removeCheckoutCoupon(checkoutId);
2387
2462
 
2388
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.
2389
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).
2466
+
2390
2467
  #### Cart Totals
2391
2468
 
2392
2469
  ```typescript
@@ -2977,6 +3054,46 @@ enforcement.
2977
3054
  - `per_option` → each SELECT option has its own price
2978
3055
  - `conditional` → surcharge when NUMBER value meets condition (gt, gte, lt, lte, eq)
2979
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
+
2980
3097
  #### Checkout Type Definition
2981
3098
 
2982
3099
  ```typescript
@@ -2993,14 +3110,39 @@ interface Checkout {
2993
3110
  shippingAmount: string;
2994
3111
  taxAmount: string; // "0" in inclusive (VAT) mode — see taxBreakdown.totalTax
2995
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].
2996
3115
  total: string;
2997
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;
2998
3124
  notes?: string | null; // Order note from setCheckoutCustomer/setShippingAddress
2999
3125
  items: CheckoutLineItem[];
3000
3126
  itemCount: number;
3001
3127
  availableShippingRates?: ShippingRate[];
3002
3128
  }
3003
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
+
3004
3146
  type CheckoutStatus = 'PENDING' | 'SHIPPING_SET' | 'PAYMENT_PENDING' | 'COMPLETED' | 'FAILED';
3005
3147
 
3006
3148
  interface ShippingRate {
@@ -3217,9 +3359,11 @@ const { hasPayments, providers, defaultProvider } = await client.getPaymentProvi
3217
3359
  const expressMethods = providers.filter(p => p.isAdditive); // e.g. PayPal
3218
3360
  const primary = defaultProvider && !defaultProvider.isAdditive ? defaultProvider : undefined;
3219
3361
 
3220
- // 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.
3221
3365
  if (!hasPayments) {
3222
- return <div>Payment not configured for this store</div>;
3366
+ return <div>Payment is not set up for this store yet</div>;
3223
3367
  }
3224
3368
 
3225
3369
  const stripeProvider = providers.find(p => p.provider === 'stripe');
@@ -4511,6 +4655,126 @@ console.log(store.language); // 'en', 'he', etc.
4511
4655
 
4512
4656
  ---
4513
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
+
4514
4778
  ### Traffic Analytics (built-in, no GA4 needed)
4515
4779
 
4516
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.
@@ -5033,12 +5297,21 @@ const mobileOnlyZone = await client.createShippingZone({
5033
5297
  const rates = await client.getZoneShippingRates('zone_id');
5034
5298
  await client.createZoneShippingRate('zone_id', {
5035
5299
  name: 'Standard Shipping',
5036
- type: 'flat',
5037
- price: 5.99,
5038
- estimatedDays: '3-5',
5300
+ type: 'FLAT_RATE',
5301
+ rateConfig: { amount: 5.99 },
5302
+ minDeliveryDays: 3,
5303
+ maxDeliveryDays: 5,
5039
5304
  });
5040
5305
  ```
5041
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
+
5042
5315
  ### App Store Shipping (live carrier rates)
5043
5316
 
5044
5317
  Once a merchant installs a shipping app from the Brainerce App Store (EasyPost, Shippo, or any
@@ -5179,6 +5452,58 @@ await client.createTaxRate({
5179
5452
  });
5180
5453
  ```
5181
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
+
5182
5507
  ### Tax Classes
5183
5508
 
5184
5509
  Tax classes let you charge different rates for different product types (e.g.
@@ -6289,6 +6614,7 @@ export default function CheckoutPage() {
6289
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.
6290
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.
6291
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)
6292
6618
  > - Call `client.onCheckoutComplete()` after successful payment to clear the session cart
6293
6619
  > - Call `client.syncCartOnLogin()` when a user logs in to merge their guest cart
6294
6620
 
@@ -6852,6 +7178,61 @@ The merchant reads the demand at `Products → Back-in-Stock Waitlist`: products
6852
7178
 
6853
7179
  ---
6854
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
+
6855
7236
  ## Storefront Bot (AI chat widget)
6856
7237
 
6857
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.
@@ -6929,7 +7310,7 @@ export async function POST(req: Request) {
6929
7310
 
6930
7311
  ### Webhook Events
6931
7312
 
6932
- **These 21 event types are what a subscription can actually register.** The
7313
+ **These 23 event types are what a subscription can actually register.** The
6933
7314
  backend validates the `events` array on create against exactly this list, so
6934
7315
  anything outside it is rejected rather than silently accepted.
6935
7316
 
@@ -6956,6 +7337,8 @@ anything outside it is rejected rather than silently accepted.
6956
7337
  | `payment.refunded` | Refund posted to the customer |
6957
7338
  | `blog.post.published` | Post went live (manual, scheduled, or SEO Autopilot) |
6958
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 |
6959
7342
 
6960
7343
  Payload shapes for each are in the
6961
7344
  [Event Catalogue](https://brainerce.com/docs/webhooks/events).
@@ -6964,7 +7347,7 @@ Payload shapes for each are in the
6964
7347
  just merchant-created customers, so a storefront that registers customers will
6965
7348
  start seeing it.
6966
7349
 
6967
- The `WebhookEventType` type matches this table exactly as of SDK 2.0.2
7350
+ The `WebhookEventType` type matches this table exactly as of SDK 2.1.0 —
6968
7351
  `isWebhookEventType(event, 'customer.created')` and
6969
7352
  `createWebhookHandler({ 'order.paid': … })` both compile and match the
6970
7353
  subscribable set. (Previous SDK versions shipped a stale 15-entry type that
@@ -7054,6 +7437,7 @@ When building a store, implement these pages:
7054
7437
  - [ ] **Auth Callback** (`/auth/callback`) - Handle OAuth redirects from Google/Facebook/GitHub
7055
7438
  - [ ] **Verify Email** (`/verify-email`) - Email verification with 6-digit code (if store requires it)
7056
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
7057
7441
 
7058
7442
  ### ⚠️ Payment Page is REQUIRED
7059
7443