brainerce 1.59.0 → 1.63.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 +239 -27
- package/dist/index.d.mts +470 -16
- package/dist/index.d.ts +470 -16
- package/dist/index.js +321 -23
- package/dist/index.mjs +320 -23
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -62,13 +62,14 @@ Every Brainerce storefront must include **all mandatory features** below. Featur
|
|
|
62
62
|
| Login + verification branch | `client.loginCustomer()` | ✅ |
|
|
63
63
|
| Forgot / reset password | `client.forgotPassword()`, `client.resetPassword()` | ✅ |
|
|
64
64
|
| OAuth sign-in buttons + callback handler | `client.getAvailableOAuthProviders()` | ✅ |
|
|
65
|
-
| Account area (profile + order history) | `client.getMyProfile()`, `client.getMyOrders()`
|
|
65
|
+
| Account area (profile + order history) | `client.getMyProfile()`, `client.updateMyProfile()`, `client.getMyOrders()` | ✅ |
|
|
66
66
|
| Loyalty & rewards (points balance + tiers + redeem) | `client.getLoyaltyStatus()`, `client.getAvailableRewards()`, `client.getRecommendedReward()`, `client.redeemLoyaltyReward(id)`, `client.reportSocialShare()` | conditional |
|
|
67
67
|
| Loyalty paid membership (premium subscription) | `client.getMembershipPlans()`, `client.getMySavedPaymentMethods()`, `client.subscribeToMembership(params)`, `client.cancelMembership()` | conditional |
|
|
68
68
|
| Embeddable loyalty widget (points + rewards on ANY site) | `client.getLoyaltyWidgetSession()` | conditional |
|
|
69
69
|
| Global header: cart count + search autocomplete | `client.getCart()`, `client.getSearchSuggestions(query)` | ✅ |
|
|
70
70
|
| Discount banners + product badges | `client.getDiscountBanners()`, `client.getProductDiscountBadge(productId)` | ✅ |
|
|
71
71
|
| Product reviews on PDP + JSON-LD aggregateRating | `client.listProductReviews(id)`, `client.submitProductReview(id, …)` | ✅ |
|
|
72
|
+
| Customer photos on reviews | `client.uploadReviewPhoto(productId, file)`, then `imageKeys` on submit | conditional |
|
|
72
73
|
| Site chrome (header + footer + announcement bar) | `client.content.header.get()`, `client.content.footer.get()`, `client.content.announcement.list()` | ✅ |
|
|
73
74
|
| FAQ page | `client.content.faq.get('main', locale)` | conditional |
|
|
74
75
|
| Static pages catch-all (`/pages/[slug]`) | `client.content.page.getBySlug(slug, locale)` | conditional |
|
|
@@ -98,6 +99,7 @@ Violating any of these causes production incidents or broken orders. Read them b
|
|
|
98
99
|
|
|
99
100
|
- ALWAYS handle the `requiresVerification` flag in `registerCustomer` and `loginCustomer` responses. If true, route to the verify-email step BEFORE treating the user as logged in.
|
|
100
101
|
- ALWAYS build the verify-email, forgot-password, and reset-password flows even when the store currently has email verification disabled. They auto-hide when unused.
|
|
102
|
+
- ALWAYS read `requireBirthday` from `getStoreInfo()` before rendering the signup form. When it is true the merchant made the birthday mandatory on that sales channel, and `registerCustomer` returns HTTP 400 unless you send both `birthMonth` (1-12) and `birthDay` (1-31). Month and day only, never a year.
|
|
101
103
|
- ALWAYS build OAuth button placeholders and a callback handler even when no OAuth provider is configured.
|
|
102
104
|
- NEVER silently swallow auth errors. Render the specific error (invalid credentials, expired token, rate limited).
|
|
103
105
|
|
|
@@ -177,10 +179,12 @@ These sequences are non-negotiable. The order of SDK calls matters.
|
|
|
177
179
|
|
|
178
180
|
### Registration flow
|
|
179
181
|
|
|
180
|
-
1. Collect email, password, first name, last name.
|
|
182
|
+
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.
|
|
181
183
|
2. Call `registerCustomer`:
|
|
182
184
|
```ts
|
|
183
185
|
const result = await client.registerCustomer({ email, password, firstName, lastName });
|
|
186
|
+
// Channel requires a birthday? Send both fields, never a year:
|
|
187
|
+
// { email, password, firstName, lastName, birthMonth: 4, birthDay: 17 }
|
|
184
188
|
```
|
|
185
189
|
3. Branch on `result.requiresVerification`:
|
|
186
190
|
- `true` → store token temporarily, route to verify-email UI (do NOT set token yet)
|
|
@@ -1907,7 +1911,8 @@ fields.forEach((field) => {
|
|
|
1907
1911
|
// field.minLength, field.maxLength: validation for text fields
|
|
1908
1912
|
// field.minValue, field.maxValue: validation for number fields
|
|
1909
1913
|
// field.dateAvailability: constraints for DATE/DATETIME fields (blocked
|
|
1910
|
-
// weekdays/dates, min/max date,
|
|
1914
|
+
// weekdays/dates, min/max date, leadTimeMinutes/cutoffTime/maxDaysAhead,
|
|
1915
|
+
// business hours + slots) — see
|
|
1911
1916
|
// computeAvailableSlots()/getBusinessHoursForDate()/isDateValueAllowed()
|
|
1912
1917
|
// below. DATE values are sent as "YYYY-MM-DD"; DATETIME as one ISO-8601
|
|
1913
1918
|
// value — never a date with a slot LABEL glued on ("...T13:00-14:00" is
|
|
@@ -2681,7 +2686,8 @@ const updatedCheckout = await client.setCheckoutCustomFields(checkoutId, {
|
|
|
2681
2686
|
**DATE / DATETIME fields with availability constraints**
|
|
2682
2687
|
|
|
2683
2688
|
A `DATE`/`DATETIME` field's `dateAvailability` (blocked weekdays, blocked specific
|
|
2684
|
-
dates, min/max date range,
|
|
2689
|
+
dates, min/max date range, the relative bounds `leadTimeMinutes` / `cutoffTime` /
|
|
2690
|
+
`maxDaysAhead`, and — for `DATETIME` — business hours + time
|
|
2685
2691
|
slots) is a merchant-configured restriction on which values the customer may
|
|
2686
2692
|
pick. Use `computeAvailableSlots()` / `getBusinessHoursForDate()` /
|
|
2687
2693
|
`isDateValueAllowed()` to drive your own date-picker/slot-picker UI — the SDK
|
|
@@ -2700,19 +2706,24 @@ const { timezone } = await client.getStoreInfo(); // IANA string, e.g. "Asia/Jer
|
|
|
2700
2706
|
const deliveryField = fields.find((f) => f.key === 'delivery_slot');
|
|
2701
2707
|
const availability = deliveryField?.dateAvailability;
|
|
2702
2708
|
|
|
2709
|
+
// The clock. Without it leadTimeMinutes/cutoffTime/maxDaysAhead are SKIPPED and
|
|
2710
|
+
// the picker offers days the server refuses; `now` defaults to this instant.
|
|
2711
|
+
const clock = { timezone };
|
|
2712
|
+
|
|
2703
2713
|
// Disable days on your calendar of choice. Note the SECOND condition: once
|
|
2704
2714
|
// businessHours has any entry it is an ALLOWLIST, so a weekday it doesn't
|
|
2705
2715
|
// mention is closed all day even though the calendar rules accept it.
|
|
2706
2716
|
const isDayDisabled = (ymd: string) =>
|
|
2707
|
-
!isCalendarDateAllowed(ymd, availability) ||
|
|
2717
|
+
!isCalendarDateAllowed(ymd, availability, clock) ||
|
|
2708
2718
|
(!!availability?.businessHours?.length &&
|
|
2709
|
-
getBusinessHoursForDate(availability, ymd).length === 0);
|
|
2719
|
+
getBusinessHoursForDate(availability, ymd, clock).length === 0);
|
|
2710
2720
|
|
|
2711
2721
|
// Once the customer picks a day, offer times. computeAvailableSlots() returns
|
|
2712
2722
|
// [] when the field has no slotDurationMinutes — that is NOT "day closed",
|
|
2713
|
-
// which is why the windows are checked separately.
|
|
2714
|
-
|
|
2715
|
-
const
|
|
2723
|
+
// which is why the windows are checked separately. A day with two windows
|
|
2724
|
+
// (mornings and evenings) yields both, in chronological order.
|
|
2725
|
+
const slots = computeAvailableSlots(availability, '2026-08-15', clock); // ["09:00", "09:30", ...]
|
|
2726
|
+
const windows = getBusinessHoursForDate(availability, '2026-08-15', clock); // [{ weekday, open, close }]
|
|
2716
2727
|
|
|
2717
2728
|
if (slots.length) {
|
|
2718
2729
|
// Render slot buttons; the submitted time must equal a slot start exactly.
|
|
@@ -2736,6 +2747,19 @@ What you read back is normalized, not the string you sent: `YYYY-MM-DD` for
|
|
|
2736
2747
|
`DATE`, an ISO-8601 UTC instant for `DATETIME`. Use `parseDateFieldValue()` if
|
|
2737
2748
|
you want to apply the exact same parse client-side before submitting.
|
|
2738
2749
|
|
|
2750
|
+
**Relative bounds.** `leadTimeMinutes` puts the earliest bookable moment at
|
|
2751
|
+
`now + leadTime`. `cutoffTime` ("HH:mm", store-local) pushes the earliest
|
|
2752
|
+
bookable DATE on by a further day once the store clock reaches it, which is how
|
|
2753
|
+
"order by 14:00 for tomorrow" is expressed. `maxDaysAhead` is a rolling ceiling
|
|
2754
|
+
counted from today. All three are re-resolved on every call, so unlike an
|
|
2755
|
+
absolute `minDate` they never go stale, and all three apply to plain `DATE`
|
|
2756
|
+
fields as well. They are accepted on **checkout** custom fields only: a product
|
|
2757
|
+
metafield and an order custom field are written by an admin rather than picked
|
|
2758
|
+
by a shopper, so there is no ordering moment to measure them from and the API
|
|
2759
|
+
rejects them there. `resolveRelativeBounds(availability, clock)` returns the
|
|
2760
|
+
concrete dates they currently mean, which is what to show a shopper who asked
|
|
2761
|
+
for something too soon.
|
|
2762
|
+
|
|
2739
2763
|
The backend independently re-validates every submitted value against the same
|
|
2740
2764
|
constraints at write time — this is a client-side UX aid, not the source of
|
|
2741
2765
|
enforcement.
|
|
@@ -3481,12 +3505,24 @@ if (intent.clientSdk?.renderType === 'sandbox') {
|
|
|
3481
3505
|
> characters" produces a 400 the shopper cannot explain, and render the server's
|
|
3482
3506
|
> message verbatim when one comes back.
|
|
3483
3507
|
|
|
3508
|
+
> **Birthday fields.** `birthMonth` (1-12) and `birthDay` (1-31) are optional,
|
|
3509
|
+
> month and day only, never a year. Send both or neither: one on its own is
|
|
3510
|
+
> rejected with HTTP 400, and so is a day the month does not have. When
|
|
3511
|
+
> `getStoreInfo().requireBirthday` is true the merchant made the birthday
|
|
3512
|
+
> mandatory on that sales channel and a register call without it fails with
|
|
3513
|
+
> HTTP 400. That flag only reaches sales-channel mode (`salesChannelId`); a
|
|
3514
|
+
> `storeId`-mode storefront never receives it and its register route never
|
|
3515
|
+
> enforces it.
|
|
3516
|
+
|
|
3484
3517
|
```typescript
|
|
3485
3518
|
const auth = await client.registerCustomer({
|
|
3486
3519
|
email: 'customer@example.com',
|
|
3487
3520
|
password: 'SecurePass123!',
|
|
3488
3521
|
firstName: 'John',
|
|
3489
3522
|
lastName: 'Doe',
|
|
3523
|
+
// Optional, unless getStoreInfo().requireBirthday is true. Both or neither.
|
|
3524
|
+
birthMonth: 4,
|
|
3525
|
+
birthDay: 17,
|
|
3490
3526
|
});
|
|
3491
3527
|
|
|
3492
3528
|
// Check if email verification is required
|
|
@@ -3557,6 +3593,12 @@ console.log(profile.firstName);
|
|
|
3557
3593
|
console.log(profile.email);
|
|
3558
3594
|
console.log(profile.addresses);
|
|
3559
3595
|
|
|
3596
|
+
// Birthday the customer saved: month and day only, never a year. Both fields
|
|
3597
|
+
// arrive together or neither does, so testing one is enough.
|
|
3598
|
+
if (profile.birthMonth && profile.birthDay) {
|
|
3599
|
+
console.log(`Birthday: ${profile.birthDay}/${profile.birthMonth}`);
|
|
3600
|
+
}
|
|
3601
|
+
|
|
3560
3602
|
// profile.role is a free-form segment the merchant sets from the dashboard
|
|
3561
3603
|
// (e.g. "wholesale", "vip") — not customer-editable. Use it to gate custom
|
|
3562
3604
|
// storefront features/UI: wholesale pricing, a VIP section, etc.
|
|
@@ -3565,6 +3607,30 @@ if (profile.role === 'wholesale') {
|
|
|
3565
3607
|
}
|
|
3566
3608
|
```
|
|
3567
3609
|
|
|
3610
|
+
#### Update Customer Profile
|
|
3611
|
+
|
|
3612
|
+
Storefront or vibe-coded mode, requires `customerToken`. The call returns the
|
|
3613
|
+
saved `CustomerProfile`, so re-render the form from the response instead of
|
|
3614
|
+
from what you sent. `email` and `role` are not customer-editable and are not
|
|
3615
|
+
accepted here.
|
|
3616
|
+
|
|
3617
|
+
```typescript
|
|
3618
|
+
const updated = await client.updateMyProfile({
|
|
3619
|
+
firstName: 'John',
|
|
3620
|
+
lastName: 'Doe',
|
|
3621
|
+
phone: '+15550100',
|
|
3622
|
+
acceptsMarketing: true,
|
|
3623
|
+
// Birthday: month and day only, never a year. Send both or neither, and the
|
|
3624
|
+
// day has to exist in the month (day 31 in February is rejected with 400).
|
|
3625
|
+
birthMonth: 4,
|
|
3626
|
+
birthDay: 17,
|
|
3627
|
+
});
|
|
3628
|
+
|
|
3629
|
+
// The saved birthday comes back on the response and on getMyProfile(), so the
|
|
3630
|
+
// profile form shows what the customer stored instead of two empty fields.
|
|
3631
|
+
console.log(updated.birthMonth, updated.birthDay);
|
|
3632
|
+
```
|
|
3633
|
+
|
|
3568
3634
|
#### Get Customer Orders
|
|
3569
3635
|
|
|
3570
3636
|
```typescript
|
|
@@ -3646,10 +3712,15 @@ await client.registerCustomer({ email, password, referralCode: refFromQuery });
|
|
|
3646
3712
|
// bonus (held through the program's pending window, like order points).
|
|
3647
3713
|
```
|
|
3648
3714
|
|
|
3649
|
-
Birthday gifts need no SDK calls beyond profile data:
|
|
3650
|
-
`birthMonth`/`birthDay` (1-12 / 1-31,
|
|
3651
|
-
platform emails a one-time gift coupon ahead of
|
|
3652
|
-
(when the store has it enabled).
|
|
3715
|
+
Birthday gifts need no SDK calls beyond profile data: save the customer's
|
|
3716
|
+
`birthMonth`/`birthDay` (1-12 / 1-31, month and day only, never a year) via
|
|
3717
|
+
`updateMyProfile()` and the platform emails a one-time gift coupon ahead of
|
|
3718
|
+
their birthday automatically (when the store has it enabled). Both values come
|
|
3719
|
+
back on `getMyProfile()`, on `getCheckoutPrefillData()` and on the `Customer`
|
|
3720
|
+
read types, so a profile form renders the birthday the customer already gave
|
|
3721
|
+
you rather than an empty pair of fields. `registerCustomer()` accepts the same
|
|
3722
|
+
two fields, and a channel with `requireBirthday` turned on insists on them at
|
|
3723
|
+
signup.
|
|
3653
3724
|
|
|
3654
3725
|
#### Paid Loyalty Membership
|
|
3655
3726
|
|
|
@@ -3741,6 +3812,8 @@ const auth = await client.registerCustomer({
|
|
|
3741
3812
|
email: 'customer@example.com',
|
|
3742
3813
|
password: 'SecurePass123!',
|
|
3743
3814
|
firstName: 'John',
|
|
3815
|
+
// Add birthMonth + birthDay here too when getStoreInfo().requireBirthday is
|
|
3816
|
+
// true, or this call fails with HTTP 400 before any email is sent.
|
|
3744
3817
|
});
|
|
3745
3818
|
|
|
3746
3819
|
if (auth.requiresVerification) {
|
|
@@ -5146,12 +5219,17 @@ is what stops an API key from escalating its own team permissions. Pointing the
|
|
|
5146
5219
|
the correct path would earn a `403` instead of a `404`. Invite, re-scope and remove
|
|
5147
5220
|
members in the dashboard.
|
|
5148
5221
|
|
|
5149
|
-
> **The older account-level methods are not
|
|
5222
|
+
> **The older account-level methods are not a substitute for this.** `getTeamMembers`,
|
|
5150
5223
|
> `getTeamInvitations`, `inviteTeamMember`, `resendTeamInvitation`, `revokeTeamInvitation`,
|
|
5151
5224
|
> `updateTeamMemberRole` and `removeTeamMember` do still reach `/api/v1/team/…` — but they
|
|
5152
|
-
> manage the **account** team, not a store's
|
|
5153
|
-
>
|
|
5154
|
-
>
|
|
5225
|
+
> manage the **account** team, not a store's. They will not invite anyone to a store or
|
|
5226
|
+
> scope a member to a sales channel; only the dashboard does that.
|
|
5227
|
+
>
|
|
5228
|
+
> **For the account team, they remain the supported call.** All seven are tagged
|
|
5229
|
+
> `@deprecated`, which records an intent to retire them — not a migration you can perform
|
|
5230
|
+
> today. There is no API-key replacement: the store-level methods named above are
|
|
5231
|
+
> dashboard-only. Keep using these until an API-key route ships, and expect the tag to
|
|
5232
|
+
> outlive this note.
|
|
5155
5233
|
|
|
5156
5234
|
### Email Settings & Templates
|
|
5157
5235
|
|
|
@@ -6082,6 +6160,8 @@ export default function RegisterPage() {
|
|
|
6082
6160
|
setLoading(true);
|
|
6083
6161
|
setError('');
|
|
6084
6162
|
try {
|
|
6163
|
+
// Add birthMonth + birthDay to this call (both, never a year) when
|
|
6164
|
+
// getStoreInfo().requireBirthday is true for the channel.
|
|
6085
6165
|
const auth = await client.registerCustomer({ email, password, firstName, lastName });
|
|
6086
6166
|
|
|
6087
6167
|
// Check if email verification is required
|
|
@@ -6183,6 +6263,9 @@ export default function AccountPage() {
|
|
|
6183
6263
|
<h2 className="text-xl font-bold mb-4">Profile</h2>
|
|
6184
6264
|
<p><strong>Name:</strong> {profile.firstName} {profile.lastName}</p>
|
|
6185
6265
|
<p><strong>Email:</strong> {profile.email}</p>
|
|
6266
|
+
{profile.birthMonth && profile.birthDay && (
|
|
6267
|
+
<p><strong>Birthday:</strong> {profile.birthDay}/{profile.birthMonth}</p>
|
|
6268
|
+
)}
|
|
6186
6269
|
</div>
|
|
6187
6270
|
|
|
6188
6271
|
<div className="border rounded p-6">
|
|
@@ -6304,6 +6387,89 @@ const forms = await brainerce.contactForms.list();
|
|
|
6304
6387
|
|
|
6305
6388
|
**Rate limit:** 3 submissions per 60 seconds per IP. Include a hidden honeypot field (and do not submit it) — bots that auto-fill every input will be rejected.
|
|
6306
6389
|
|
|
6390
|
+
**A form keyed `newsletter` is still an inquiry.** The key is a label, not a behaviour: the submission files a message and never touches marketing consent, so that address can never receive a campaign. For a mailing list, use [Newsletter Signup](#newsletter-signup-marketing-opt-in).
|
|
6391
|
+
|
|
6392
|
+
---
|
|
6393
|
+
|
|
6394
|
+
## Newsletter Signup (marketing opt-in)
|
|
6395
|
+
|
|
6396
|
+
**SDK >= 1.60.** The email-capture popup, the footer subscribe bar, the exit-intent modal.
|
|
6397
|
+
|
|
6398
|
+
```typescript
|
|
6399
|
+
await brainerce.marketing.subscribe({
|
|
6400
|
+
email: 'jane@example.com',
|
|
6401
|
+
locale: 'he', // language of the confirmation email
|
|
6402
|
+
source: 'popup', // free-form, for the merchant's reporting
|
|
6403
|
+
honeypot: hiddenFieldValue, // must be empty
|
|
6404
|
+
});
|
|
6405
|
+
// → { ok: true }
|
|
6406
|
+
```
|
|
6407
|
+
|
|
6408
|
+
Also accepts `firstName`, `lastName`, and `sourceMetadata` (referrer, UTM params, the page the popup fired on).
|
|
6409
|
+
|
|
6410
|
+
**⛔ It does not subscribe anyone.** The contact is created and mailed a confirmation link; the address is unmailable — and invisible to every campaign audience — until the recipient clicks it. Render **"Check your email to confirm — including your spam folder"** on success, never "You're subscribed". The spam-folder half matters: a confirmation filtered there is the commonest reason a signup never converts, and the 24-hour resend cooldown means no second copy arrives. Single opt-in is not available: without the click, anyone could subscribe anyone else's address.
|
|
6411
|
+
|
|
6412
|
+
**⛔ The response carries no information.** `{ ok: true }` is returned identically for a brand-new address, one that confirmed months ago, one inside its 24-hour resend cooldown, and one suppressed after a hard bounce — otherwise the form would become a way to test who shops at this store. Show one message for every success; there is no branch to write.
|
|
6413
|
+
|
|
6414
|
+
**Rate limit:** 3 requests per 60 seconds per IP, plus one confirmation email per address per store per 24 hours. A submission inside that cooldown still returns `{ ok: true }` and silently sends nothing — do not treat it as a failure or retry it.
|
|
6415
|
+
|
|
6416
|
+
**Locale:** pass it on a multi-language storefront, or the confirmation email falls back to the store's language. `he` and `en` are written; anything else gets English.
|
|
6417
|
+
|
|
6418
|
+
**No discount code is minted.** For a "10% off your first order" popup, the merchant creates one coupon with the `customer_first_order` condition and you display that fixed code after a successful call.
|
|
6419
|
+
|
|
6420
|
+
The contact appears at `Customers` in the dashboard immediately, with **Accepts marketing** off; it flips on at confirmation. It is an ordinary guest customer record — no password, no account — and is the same row if that person later registers or checks out.
|
|
6421
|
+
|
|
6422
|
+
---
|
|
6423
|
+
|
|
6424
|
+
## Back-in-Stock Alerts
|
|
6425
|
+
|
|
6426
|
+
**SDK >= 1.61.** The "email me when this is back" button on a sold-out product.
|
|
6427
|
+
|
|
6428
|
+
```typescript
|
|
6429
|
+
await brainerce.stockAlerts.subscribe({
|
|
6430
|
+
email: 'jane@example.com',
|
|
6431
|
+
productId: product.id,
|
|
6432
|
+
variantId: selectedVariant.id, // pass on ANY product with variants
|
|
6433
|
+
locale: 'he', // language of the alert email
|
|
6434
|
+
honeypot: hiddenFieldValue, // must be empty
|
|
6435
|
+
});
|
|
6436
|
+
// → { ok: true }
|
|
6437
|
+
```
|
|
6438
|
+
|
|
6439
|
+
**⛔ It is not a subscription.** One email, about one item, carrying a link that stops it. No customer account is created and no marketing consent is granted. Label the button **"Email me when it's back"**, never "Subscribe" — and because it grants no consent, never hide it from a shopper who unsubscribed from your marketing.
|
|
6440
|
+
|
|
6441
|
+
**⛔ Render it only when `getStoreInfo().stockAlertsEnabled !== false`, the item is out of stock, AND it cannot be backordered.** Requests for anything else — a storefront whose merchant switched the feature off, an in-stock item, a backorderable one, an untracked one, an unknown product id — are silently ignored, so a button in the wrong place looks like it worked and does nothing.
|
|
6442
|
+
|
|
6443
|
+
```typescript
|
|
6444
|
+
const store = await brainerce.getStoreInfo();
|
|
6445
|
+
// `inv` is the SELECTED VARIANT's inventory when there is one, else the product's.
|
|
6446
|
+
const inv = selectedVariant?.inventory ?? product.inventory;
|
|
6447
|
+
|
|
6448
|
+
const canOfferStockAlert =
|
|
6449
|
+
store.stockAlertsEnabled !== false &&
|
|
6450
|
+
inv?.trackingMode === 'TRACKED' &&
|
|
6451
|
+
!inv.canPurchase &&
|
|
6452
|
+
(inv.backorderMode ?? 'NONE') === 'NONE';
|
|
6453
|
+
```
|
|
6454
|
+
|
|
6455
|
+
`backorderMode` is on `InventoryInfo` from SDK 1.61; older backends omit it, so treat `undefined` as `'NONE'`.
|
|
6456
|
+
|
|
6457
|
+
The merchant controls the switch — and how many people are emailed per unit restocked — under **Channel settings → Inventory**, alongside the low-stock warning.
|
|
6458
|
+
|
|
6459
|
+
**⛔ Pass `variantId` on every variable product.** Without it the alert waits on the product as a whole, so a shopper who wanted the medium is mailed when the small returns and arrives to find their size still gone.
|
|
6460
|
+
|
|
6461
|
+
**⛔ The response carries no information.** `{ ok: true }` is returned identically for a new request, a duplicate, an unknown product, an item already in stock, and an address suppressed after a hard bounce — otherwise the button would become a way to read the store's stock levels. Show one message for every success; there is no branch to write.
|
|
6462
|
+
|
|
6463
|
+
**Sending is not immediate, and not to everyone.** Availability is `total - reserved`, so an expiring cart briefly lifts a sold-out item above zero; the alert waits for stock to hold for a few minutes, then goes out in waves sized to the units that came back (500 waiting and 3 units restocked is roughly 9 emails, oldest request first). A shopper can therefore sit through a restock without hearing, so never promise "you'll be the first to know".
|
|
6464
|
+
|
|
6465
|
+
**Rate limit:** 5 requests per 60 seconds per IP, at most 25 open alerts per address per store, and a 90-day life on an unfired alert. A duplicate request is a no-op, not a second alert; once an alert has fired the person can ask again the next time that item sells out.
|
|
6466
|
+
|
|
6467
|
+
**Locale:** pass it on a multi-language storefront, or the alert falls back to the store's language. `he` and `en` are written; anything else gets English.
|
|
6468
|
+
|
|
6469
|
+
**What it does not do:** no SMS or WhatsApp, no price-drop alerts, and no merchant-editable template — the body is fixed so it can never start carrying a discount code, which would turn a transactional message into a marketing one needing an unsubscribe link it does not have.
|
|
6470
|
+
|
|
6471
|
+
The merchant reads the demand at `Products → Back-in-Stock Waitlist`: products ranked by how many people are waiting, with the addresses behind each number. There is no way to mail those people anything else from there, by design.
|
|
6472
|
+
|
|
6307
6473
|
---
|
|
6308
6474
|
|
|
6309
6475
|
## Storefront Bot (AI chat widget)
|
|
@@ -6383,16 +6549,62 @@ export async function POST(req: Request) {
|
|
|
6383
6549
|
|
|
6384
6550
|
### Webhook Events
|
|
6385
6551
|
|
|
6386
|
-
|
|
6387
|
-
|
|
6388
|
-
|
|
6389
|
-
|
|
6390
|
-
|
|
|
6391
|
-
|
|
|
6392
|
-
| `order.created`
|
|
6393
|
-
| `order.updated`
|
|
6394
|
-
| `
|
|
6395
|
-
| `
|
|
6552
|
+
**These 21 event types are what a subscription can actually register.** The
|
|
6553
|
+
backend validates the `events` array on create against exactly this list, so
|
|
6554
|
+
anything outside it is rejected rather than silently accepted.
|
|
6555
|
+
|
|
6556
|
+
| Event | Description |
|
|
6557
|
+
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
6558
|
+
| `order.created` | New order placed (any payment status) |
|
|
6559
|
+
| `order.updated` | Order metadata changed (status, address, items) |
|
|
6560
|
+
| `order.paid` | Order is paid — 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 |
|
|
6561
|
+
| `order.fulfilled` | All items marked shipped/delivered |
|
|
6562
|
+
| `order.cancelled` | Order cancelled (by merchant or customer) |
|
|
6563
|
+
| `order.refunded` | Order fully or partially refunded |
|
|
6564
|
+
| `customer.created` | New customer account created |
|
|
6565
|
+
| `customer.updated` | Customer profile or contact details changed |
|
|
6566
|
+
| `customer.deleted` | Customer account deleted |
|
|
6567
|
+
| `product.created` | New product added to catalog |
|
|
6568
|
+
| `product.updated` | Product attributes, variants, or pricing changed |
|
|
6569
|
+
| `product.deleted` | Product removed from catalog |
|
|
6570
|
+
| `inventory.updated` | Stock level changed (any reason) |
|
|
6571
|
+
| `inventory.low` | Stock fell below the low-stock threshold |
|
|
6572
|
+
| `checkout.completed` | Checkout completed (synonym of `order.created` for now) |
|
|
6573
|
+
| `checkout.abandoned` | Cart inactive for 1+ hours with no completion |
|
|
6574
|
+
| `payment.succeeded` | Payment provider confirmed funds captured |
|
|
6575
|
+
| `payment.failed` | Payment provider rejected the transaction |
|
|
6576
|
+
| `payment.refunded` | Refund posted to the customer |
|
|
6577
|
+
| `blog.post.published` | Post went live (manual, scheduled, or SEO Autopilot) |
|
|
6578
|
+
| `blog.post.updated` | Published post content changed |
|
|
6579
|
+
|
|
6580
|
+
Payload shapes for each are in the
|
|
6581
|
+
[Event Catalogue](https://brainerce.com/docs/webhooks/events).
|
|
6582
|
+
|
|
6583
|
+
`customer.created` now fires for **shopper self-signup on your storefront**, not
|
|
6584
|
+
just merchant-created customers, so a storefront that registers customers will
|
|
6585
|
+
start seeing it.
|
|
6586
|
+
|
|
6587
|
+
> **⚠️ The `WebhookEventType` type does not match this table yet — in both
|
|
6588
|
+
> directions.** Treat the table, not the type, as the truth about what you can
|
|
6589
|
+
> subscribe to.
|
|
6590
|
+
>
|
|
6591
|
+
> **14 subscribable events are missing from the type:** `order.paid`,
|
|
6592
|
+
> `order.fulfilled`, `order.cancelled`, `order.refunded`, `customer.created`,
|
|
6593
|
+
> `customer.updated`, `customer.deleted`, `inventory.low`, `checkout.abandoned`,
|
|
6594
|
+
> `payment.succeeded`, `payment.failed`, `payment.refunded`,
|
|
6595
|
+
> `blog.post.published`, `blog.post.updated`. So
|
|
6596
|
+
> `isWebhookEventType(event, 'customer.created')` and a
|
|
6597
|
+
> `createWebhookHandler({ 'order.paid': … })` key **fail to compile**, even
|
|
6598
|
+
> though both deliver correctly at runtime. Cast the name
|
|
6599
|
+
> (`'customer.created' as WebhookEventType`) or read `event.event` as a
|
|
6600
|
+
> `string` and switch on it yourself. Do not conclude the event does not exist.
|
|
6601
|
+
>
|
|
6602
|
+
> **8 names in the type cannot be subscribed to at all:** `coupon.created`,
|
|
6603
|
+
> `coupon.updated`, `coupon.deleted`, `cart.created`, `cart.updated`,
|
|
6604
|
+
> `cart.abandoned`, `checkout.started`, `checkout.failed`. These compile
|
|
6605
|
+
> cleanly and then fail at subscription time. `cart.abandoned` in particular
|
|
6606
|
+
> was listed as a supported event here for a long time — use
|
|
6607
|
+
> `checkout.abandoned` instead.
|
|
6396
6608
|
|
|
6397
6609
|
---
|
|
6398
6610
|
|