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 +799 -113
- package/dist/index.d.mts +970 -176
- package/dist/index.d.ts +970 -176
- package/dist/index.js +517 -195
- package/dist/index.mjs +517 -195
- package/package.json +5 -4
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
|
|
53
|
-
|
|
|
54
|
-
| Product list with search, filter, pagination
|
|
55
|
-
| Product detail with variant picker, stock, price
|
|
56
|
-
| Buyer customization fields (engraving, uploads, select)
|
|
57
|
-
| Cart (add, update, remove, coupon, totals)
|
|
58
|
-
| Inventory reservation countdown
|
|
59
|
-
| Full checkout end-to-end with payment
|
|
60
|
-
|
|
|
61
|
-
|
|
|
62
|
-
|
|
|
63
|
-
|
|
|
64
|
-
|
|
|
65
|
-
|
|
|
66
|
-
|
|
|
67
|
-
| Loyalty
|
|
68
|
-
|
|
|
69
|
-
|
|
|
70
|
-
|
|
|
71
|
-
|
|
|
72
|
-
|
|
|
73
|
-
|
|
|
74
|
-
|
|
|
75
|
-
|
|
|
76
|
-
|
|
|
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
|
|
310
|
-
| ---------------------------------------------- |
|
|
311
|
-
| `formatPrice(amount, { currency?, locale? })` | Format prices for display
|
|
312
|
-
| `getPriceDisplay(amount, currency?, locale?)` | Alias for `formatPrice`
|
|
313
|
-
| `getDescriptionContent(product)` | Get product description (HTML or text)
|
|
314
|
-
| `isHtmlDescription(product)` | Check if description is HTML
|
|
315
|
-
| `getStockStatus(inventory)`
|
|
316
|
-
| `getProductPrice(product)` | Get effective price (handles sales)
|
|
317
|
-
| `getProductPriceInfo(product)` | Get price + sale info + discount % (falls back to `priceMin` when `basePrice=0` on VARIABLE)
|
|
318
|
-
| `getVariantPrice(variant, basePrice)` | Get variant price with fallback
|
|
319
|
-
| `getCartTotals(cart, shippingPrice?)` | Calculate cart subtotal/discount/total
|
|
320
|
-
| `getCartItemName(item)` | Get name from nested cart item (product + variant)
|
|
321
|
-
| `getCartItemImage(item)` | Get image URL from cart item
|
|
322
|
-
| `getVariantOptions(variant)` | Get variant attributes as array
|
|
323
|
-
| `isCouponApplicableToProduct(coupon, product)` | Check if coupon applies
|
|
324
|
-
| `isAllowedPaymentUrl(url, options?)` | Validate a payment URL host
|
|
325
|
-
| `safePaymentRedirect(url, options?)` | Validate then `window.location.href`
|
|
326
|
-
| `buildProductJsonLd(product, opts)` | schema.org Product JSON-LD (PDPs only)
|
|
327
|
-
| `buildArticleJsonLd(post, opts)` | schema.org Article JSON-LD for blog posts
|
|
328
|
-
| `buildOrganizationJsonLd(store, opts)` | schema.org Organization for the homepage
|
|
329
|
-
| `buildBreadcrumbJsonLd(items)` | schema.org BreadcrumbList
|
|
330
|
-
| `buildProductFaqJsonLd(product)` | schema.org FAQPage from `product.faq` (null when empty); render the same pairs as visible text
|
|
331
|
-
| `jsonLdScriptProps(data)` | XSS-safe `<script type="application/ld+json">` props
|
|
332
|
-
| `getBlogSitemapEntries(client, opts)` | Paginate published posts into sitemap entries
|
|
333
|
-
| `getProductSitemapEntries(client, opts)` | ALL published products into sitemap entries (no 100-item clamp)
|
|
334
|
-
| `getCategorySitemapEntries(client, opts)` | Category tree into sitemap entries
|
|
335
|
-
| `client.resolveSlugRedirect(type, slug)` | Renamed slug → current slug (301 support in not-found paths)
|
|
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
|
-
|
|
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
|
-
//
|
|
388
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1229
|
-
|
|
1230
|
-
|
|
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
|
|
1239
|
-
|
|
1240
|
-
|
|
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
|
-
|
|
1245
|
-
|
|
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
|
|
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: '
|
|
5037
|
-
|
|
5038
|
-
|
|
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
|
|
5443
|
-
`
|
|
5444
|
-
`
|
|
5445
|
-
|
|
5446
|
-
|
|
5447
|
-
|
|
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
|
|
5455
|
-
|
|
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
|
|
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
|
|
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
|
-
-
|
|
7217
|
-
-
|
|
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
|
|