brainerce 1.59.0 → 2.0.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 +560 -59
- package/dist/index.d.mts +711 -196
- package/dist/index.d.ts +711 -196
- package/dist/index.js +531 -268
- package/dist/index.mjs +530 -268
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -55,20 +55,21 @@ Every Brainerce storefront must include **all mandatory features** below. Featur
|
|
|
55
55
|
| Product detail with variant picker, stock, price | `client.getProductBySlug()` + helpers | ✅ |
|
|
56
56
|
| Buyer customization fields (engraving, uploads, select) | `product.customizationFields`, `client.uploadCustomizationFile()` | ✅ |
|
|
57
57
|
| Cart (add, update, remove, coupon, totals) | `client.addToCart()`, `getCartTotals(cart)` | ✅ |
|
|
58
|
-
| Inventory reservation countdown | Cart expiry timestamp from `client.getCart()`
|
|
58
|
+
| Inventory reservation countdown | Cart expiry timestamp from `client.getCart(cartId)` | ✅ |
|
|
59
59
|
| Full checkout end-to-end with payment | `setShippingAddress → selectShippingMethod → getPaymentProviders → pay → handlePaymentSuccess → waitForOrder` | ✅ |
|
|
60
60
|
| Order confirmation (clear cart + wait for real order) | `client.handlePaymentSuccess()`, `client.waitForOrder()` | ✅ |
|
|
61
61
|
| Register + email verification flow | `client.registerCustomer()`, `client.verifyEmail()` | ✅ |
|
|
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
|
-
| Global header: cart count + search autocomplete | `client.
|
|
69
|
+
| Global header: cart count + search autocomplete | `client.smartGetCart()`, `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
|
|
|
@@ -170,17 +172,19 @@ These sequences are non-negotiable. The order of SDK calls matters.
|
|
|
170
172
|
5. Confirm payment using the provider's flow (Stripe Elements `stripe.confirmCardPayment`, PayPal button, redirect, etc.).
|
|
171
173
|
6. On the confirmation page, **always call both**:
|
|
172
174
|
```ts
|
|
173
|
-
|
|
175
|
+
client.handlePaymentSuccess(checkoutId); // synchronous, clears cart. Do NOT await it.
|
|
174
176
|
const order = await client.waitForOrder(checkoutId); // polls until order exists
|
|
175
177
|
```
|
|
176
178
|
7. Display `checkout.lineItems` (not `cart.items`) on the order summary.
|
|
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)
|
|
@@ -206,7 +210,22 @@ These sequences are non-negotiable. The order of SDK calls matters.
|
|
|
206
210
|
### Order confirmation flow
|
|
207
211
|
|
|
208
212
|
1. Read `checkoutId` from URL or session.
|
|
209
|
-
2. `
|
|
213
|
+
2. `client.handlePaymentSuccess(checkoutId)` is mandatory. It clears the cart so purchased items don't show on the next visit. It is **synchronous** and returns a plain object, not a promise, so awaiting it is a no-op that only looks like it did something:
|
|
214
|
+
|
|
215
|
+
```ts
|
|
216
|
+
const { cleared, mode, userType, itemsRemoved } = client.handlePaymentSuccess(checkoutId);
|
|
217
|
+
// mode: 'full' the whole cart went, the normal case
|
|
218
|
+
// mode: 'partial' a partial checkout, so only the purchased lines went.
|
|
219
|
+
// `itemsRemoved` counts them and the rest stay in the cart
|
|
220
|
+
// mode: 'none' nothing to do, because this checkout was already handled
|
|
221
|
+
// in this browser session. React Strict Mode runs effects
|
|
222
|
+
// twice and a refresh re-runs the page, so this is the
|
|
223
|
+
// normal repeat-call answer: SUCCESS, not a failure.
|
|
224
|
+
// `cleared` is false here. Never show an error on it.
|
|
225
|
+
// userType: 'guest' | 'customer'
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Branch on `mode` only for what you render: a `'partial'` result means the shopper still has a cart worth linking to. Never gate `waitForOrder` on `cleared`.
|
|
210
229
|
3. `const result = await client.waitForOrder(checkoutId)` — polls until the webhook writes the order. `result.status.orderNumber` / `result.status.orderId` are available on success.
|
|
211
230
|
4. Show a spinner during step 3 (webhook may lag). On timeout: show "we're still processing, check your email" with a link to order history — the order WILL appear there.
|
|
212
231
|
5. On success: render the order number, or — if your design wants more than that — fetch full details:
|
|
@@ -276,7 +295,7 @@ the credential, no customer token needed.
|
|
|
276
295
|
### Inventory reservation flow
|
|
277
296
|
|
|
278
297
|
- Display the countdown from `cart.reservation?.expiresAt` — refresh once per second (`reservation` is optional; only present when a reservation strategy is active).
|
|
279
|
-
- On expiry: call `client.getCart()` to refresh. Items whose reservation expired are flagged server-side.
|
|
298
|
+
- On expiry: call `client.getCart(cartId)` to refresh, or `client.smartGetCart()` when you are not tracking a cart id yourself. `getCart` takes the cart id; there is no no-argument form. Items whose reservation expired are flagged server-side.
|
|
280
299
|
- On the checkout page: if reservation has expired, block payment and show "your cart has expired" with a link back to cart.
|
|
281
300
|
- Do NOT implement your own timer logic — the SDK is the source of truth.
|
|
282
301
|
|
|
@@ -1022,8 +1041,9 @@ await client.setCheckoutCustomer(checkout.id, {
|
|
|
1022
1041
|
lastName: 'Doe',
|
|
1023
1042
|
});
|
|
1024
1043
|
|
|
1025
|
-
// 5. Set shipping address
|
|
1044
|
+
// 5. Set shipping address (email is required here too, even though step 4 sent it)
|
|
1026
1045
|
await client.setShippingAddress(checkout.id, {
|
|
1046
|
+
email: 'customer@example.com',
|
|
1027
1047
|
firstName: 'John',
|
|
1028
1048
|
lastName: 'Doe',
|
|
1029
1049
|
line1: '123 Main St',
|
|
@@ -1112,6 +1132,7 @@ const region = validRegions.some((r) => r.code === address.region) ? address.reg
|
|
|
1112
1132
|
// yourself. Use `address.lat`/`address.lng` for your own UI — a map pin, a
|
|
1113
1133
|
// distance readout — and nothing else.
|
|
1114
1134
|
await client.setShippingAddress(checkout.id, {
|
|
1135
|
+
email: 'customer@example.com', // required. The resolved address carries no email.
|
|
1115
1136
|
firstName: 'John',
|
|
1116
1137
|
lastName: 'Doe',
|
|
1117
1138
|
...address,
|
|
@@ -1907,7 +1928,8 @@ fields.forEach((field) => {
|
|
|
1907
1928
|
// field.minLength, field.maxLength: validation for text fields
|
|
1908
1929
|
// field.minValue, field.maxValue: validation for number fields
|
|
1909
1930
|
// field.dateAvailability: constraints for DATE/DATETIME fields (blocked
|
|
1910
|
-
// weekdays/dates, min/max date,
|
|
1931
|
+
// weekdays/dates, min/max date, leadTimeMinutes/cutoffTime/maxDaysAhead,
|
|
1932
|
+
// business hours + slots) — see
|
|
1911
1933
|
// computeAvailableSlots()/getBusinessHoursForDate()/isDateValueAllowed()
|
|
1912
1934
|
// below. DATE values are sent as "YYYY-MM-DD"; DATETIME as one ISO-8601
|
|
1913
1935
|
// value — never a date with a slot LABEL glued on ("...T13:00-14:00" is
|
|
@@ -2104,6 +2126,160 @@ function ProductDescription({ product }: { product: Product }) {
|
|
|
2104
2126
|
|
|
2105
2127
|
---
|
|
2106
2128
|
|
|
2129
|
+
### Product Reviews
|
|
2130
|
+
|
|
2131
|
+
Customer reviews on the product page, with optional customer photos. Reviews
|
|
2132
|
+
publish immediately, there is no pending state, and the merchant hides
|
|
2133
|
+
individual reviews or photos afterwards (see [Review Moderation](#review-moderation)
|
|
2134
|
+
in the Admin API Reference).
|
|
2135
|
+
|
|
2136
|
+
Reading reviews is public: `listProductReviews` and the `avgRating` /
|
|
2137
|
+
`reviewCount` summary need no login. Writing is not. Only customers who bought
|
|
2138
|
+
the product may review it, so `getMyProductReview`, `uploadReviewPhoto`,
|
|
2139
|
+
`submitProductReview`, `updateMyProductReview` and `deleteMyProductReview` all
|
|
2140
|
+
need a customer token set with `setCustomerToken(...)` and return 401 without
|
|
2141
|
+
one. Author name and email come from the customer profile server-side; never
|
|
2142
|
+
send them.
|
|
2143
|
+
|
|
2144
|
+
The product itself carries `avgRating` and `reviewCount` for the star summary
|
|
2145
|
+
and the JSON-LD `aggregateRating`, so a rating badge on a product card costs no
|
|
2146
|
+
extra request.
|
|
2147
|
+
|
|
2148
|
+
#### List Reviews
|
|
2149
|
+
|
|
2150
|
+
```typescript
|
|
2151
|
+
const { data, meta } = await client.listProductReviews('prod_123', {
|
|
2152
|
+
page: 1,
|
|
2153
|
+
limit: 20,
|
|
2154
|
+
sort: 'photos_first', // 'photos_first' (default) | 'newest'
|
|
2155
|
+
});
|
|
2156
|
+
|
|
2157
|
+
data.forEach((review) => {
|
|
2158
|
+
console.log(review.rating, review.body, review.verifiedPurchase, review.authorName);
|
|
2159
|
+
// review.images is ALWAYS an array, already filtered to the photos shoppers
|
|
2160
|
+
// may see. Set width/height on the <img> so the gallery reserves space
|
|
2161
|
+
// instead of shifting as the photos load.
|
|
2162
|
+
review.images.forEach((img) => console.log(img.thumbnailUrl ?? img.url, img.width, img.height));
|
|
2163
|
+
});
|
|
2164
|
+
|
|
2165
|
+
console.log(meta.total); // total visible reviews
|
|
2166
|
+
```
|
|
2167
|
+
|
|
2168
|
+
`sort` defaults to `photos_first`: reviews carrying photos lead, newest first
|
|
2169
|
+
within each group. Pass `sort: 'newest'` for plain chronological order. On a
|
|
2170
|
+
store with no review photos the two orders are identical. Hidden reviews are
|
|
2171
|
+
never returned here.
|
|
2172
|
+
|
|
2173
|
+
No customer token is needed for this call: it is the one review call a
|
|
2174
|
+
signed-out shopper can make.
|
|
2175
|
+
|
|
2176
|
+
#### Read the Customer's Own Review State
|
|
2177
|
+
|
|
2178
|
+
Requires a customer token. Call it before rendering any review UI: it answers
|
|
2179
|
+
all four cases in one request, sign in, not eligible, submit, edit.
|
|
2180
|
+
|
|
2181
|
+
```typescript
|
|
2182
|
+
const { eligible, reason, myReview, photos, myImages } = await client.getMyProductReview('prod_123');
|
|
2183
|
+
|
|
2184
|
+
if (!eligible) {
|
|
2185
|
+
// reason: 'no_eligible_order' | 'reviews_disabled' | 'product_not_found' | null
|
|
2186
|
+
showMessage(reason);
|
|
2187
|
+
} else if (myReview) {
|
|
2188
|
+
renderEditForm(myReview);
|
|
2189
|
+
} else {
|
|
2190
|
+
renderSubmitForm();
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2193
|
+
// The store's live photo policy. Read it instead of hardcoding limits.
|
|
2194
|
+
photos.enabled; // render the file picker only when true
|
|
2195
|
+
photos.maxPerReview; // cap the selection at this
|
|
2196
|
+
photos.maxBytes; // reject oversized files before uploading
|
|
2197
|
+
photos.requiresApproval; // true = tell the customer their photo waits for the merchant
|
|
2198
|
+
|
|
2199
|
+
// The customer's OWN photos, including ones still pending approval, so their
|
|
2200
|
+
// upload looks queued rather than failed. `myReview.images` carries only the
|
|
2201
|
+
// publicly visible subset.
|
|
2202
|
+
myImages.forEach((img) => console.log(img.approvedAt, img.hiddenAt));
|
|
2203
|
+
```
|
|
2204
|
+
|
|
2205
|
+
#### Upload Photos
|
|
2206
|
+
|
|
2207
|
+
Requires a customer token, and the server re-checks the purchase before it
|
|
2208
|
+
stores any bytes. Upload each file first, then pass the returned keys on submit.
|
|
2209
|
+
Keys, not URLs: the server resolves each one against the store's own assets and
|
|
2210
|
+
rejects anything that is not a review photo.
|
|
2211
|
+
|
|
2212
|
+
```typescript
|
|
2213
|
+
const { photos } = await client.getMyProductReview(productId);
|
|
2214
|
+
|
|
2215
|
+
if (photos.enabled) {
|
|
2216
|
+
const uploads = await Promise.all(
|
|
2217
|
+
[...fileInput.files]
|
|
2218
|
+
.slice(0, photos.maxPerReview)
|
|
2219
|
+
.map((file) => client.uploadReviewPhoto(productId, file))
|
|
2220
|
+
);
|
|
2221
|
+
|
|
2222
|
+
// Each upload: { key, url, width, height }. `url` is for a local preview only,
|
|
2223
|
+
// `key` is what you send back.
|
|
2224
|
+
const imageKeys = uploads.map((u) => u.key);
|
|
2225
|
+
}
|
|
2226
|
+
```
|
|
2227
|
+
|
|
2228
|
+
Limits and behaviour:
|
|
2229
|
+
|
|
2230
|
+
- JPEG, PNG, WebP or GIF only. Max 5 MB and 40 megapixels per file. The server
|
|
2231
|
+
checks the real bytes, not the declared MIME type, so a renamed file is rejected.
|
|
2232
|
+
- Throttled to 10 uploads per minute, so a burst returns HTTP 429.
|
|
2233
|
+
- 403 when the store has review photos turned off, or the customer did not buy
|
|
2234
|
+
the product. 400 once the review is already at its photo cap.
|
|
2235
|
+
- EXIF is stripped, so GPS coordinates never reach the storefront, and the
|
|
2236
|
+
orientation tag is applied first, so phone photos stay upright.
|
|
2237
|
+
- A photo uploaded but never attached to a submitted review is reclaimed after 7
|
|
2238
|
+
days.
|
|
2239
|
+
|
|
2240
|
+
#### Submit a Review
|
|
2241
|
+
|
|
2242
|
+
Requires a customer token.
|
|
2243
|
+
|
|
2244
|
+
```typescript
|
|
2245
|
+
const review = await client.submitProductReview('prod_123', {
|
|
2246
|
+
rating: 5, // 1-5
|
|
2247
|
+
body: 'Arrived beautifully wrapped.', // optional
|
|
2248
|
+
imageKeys, // optional, in display order
|
|
2249
|
+
});
|
|
2250
|
+
```
|
|
2251
|
+
|
|
2252
|
+
- 403 `no_eligible_order` when the customer never bought the product. Eligibility
|
|
2253
|
+
is SHIPPED for physical products, PAID for downloadable ones.
|
|
2254
|
+
- 409 when they already reviewed this product. Call `updateMyProductReview`
|
|
2255
|
+
instead, which is why you read `getMyProductReview` first.
|
|
2256
|
+
|
|
2257
|
+
#### Edit or Delete Their Own Review
|
|
2258
|
+
|
|
2259
|
+
Requires a customer token. Both calls act on the caller's own review for that
|
|
2260
|
+
product; there is no review id to pass and no way to touch anyone else's.
|
|
2261
|
+
|
|
2262
|
+
```typescript
|
|
2263
|
+
// Rating, body and photos. authorName and verifiedPurchase are preserved from
|
|
2264
|
+
// the original submit and cannot be changed.
|
|
2265
|
+
await client.updateMyProductReview('prod_123', {
|
|
2266
|
+
rating: 4,
|
|
2267
|
+
body: 'Still good after a month of use.',
|
|
2268
|
+
imageKeys: ['key_a', 'key_b'],
|
|
2269
|
+
});
|
|
2270
|
+
|
|
2271
|
+
// Deleting frees the customer to submit a new review, subject to eligibility.
|
|
2272
|
+
await client.deleteMyProductReview('prod_123');
|
|
2273
|
+
```
|
|
2274
|
+
|
|
2275
|
+
> **`imageKeys` REPLACES the photo set on update.** Pass the keys you want to
|
|
2276
|
+
> keep, including the existing ones. Omitting the field entirely leaves the
|
|
2277
|
+
> current photos untouched; passing `[]` removes them all. The key of an
|
|
2278
|
+
> already-attached photo is `assetKey` on each entry of
|
|
2279
|
+
> `getMyProductReview().myImages`.
|
|
2280
|
+
|
|
2281
|
+
---
|
|
2282
|
+
|
|
2107
2283
|
### Cart Operations (All Users)
|
|
2108
2284
|
|
|
2109
2285
|
The `smart*` methods work for both guests and logged-in users. Guests use server-side session carts; logged-in users use server carts linked to their account.
|
|
@@ -2115,9 +2291,35 @@ await client.smartAddToCart({
|
|
|
2115
2291
|
productId: 'prod_123',
|
|
2116
2292
|
variantId: 'var_456', // Optional: for products with variants
|
|
2117
2293
|
quantity: 2,
|
|
2294
|
+
|
|
2295
|
+
// Optional: buyer customization values (engraving text, uploaded photo URL,
|
|
2296
|
+
// SELECT / MULTI_SELECT picks). Keys are the `key` of each entry in
|
|
2297
|
+
// product.customizationFields. See "Product customization fields" above.
|
|
2298
|
+
metadata: {
|
|
2299
|
+
engraving_text: 'Happy Birthday!',
|
|
2300
|
+
frame_color: 'Gold',
|
|
2301
|
+
},
|
|
2302
|
+
|
|
2303
|
+
// Optional: modifier-group picks (toppings, sauce, build-your-own).
|
|
2304
|
+
selections: [
|
|
2305
|
+
{ modifierGroupId: 'mg_bread', modifierIds: ['m_thick'] },
|
|
2306
|
+
{ modifierGroupId: 'mg_toppings', modifierIds: ['m_olive', 'm_bacon'] },
|
|
2307
|
+
],
|
|
2308
|
+
|
|
2309
|
+
// Optional: nested combo picks, keyed by the PARENT modifierId. Max 3 levels.
|
|
2310
|
+
nestedByModifierId: {
|
|
2311
|
+
m_side_drink: [{ modifierGroupId: 'mg_size', modifierIds: ['m_large'] }],
|
|
2312
|
+
},
|
|
2118
2313
|
});
|
|
2119
2314
|
```
|
|
2120
2315
|
|
|
2316
|
+
> `metadata`, `selections` and `nestedByModifierId` are the same three fields the
|
|
2317
|
+
> low-level `addToCart(cartId, item)` takes, and they behave identically here.
|
|
2318
|
+
> Reach for `smartAddToCart` on a storefront: it resolves the right cart for a
|
|
2319
|
+
> guest or a logged-in shopper, so you never need a `cartId` of your own.
|
|
2320
|
+
> Validation stays server-side, so a bad modifier set still comes back as a
|
|
2321
|
+
> `MODIFIER_VALIDATION_FAILED` envelope on `BrainerceError.details`.
|
|
2322
|
+
|
|
2121
2323
|
#### Get Cart
|
|
2122
2324
|
|
|
2123
2325
|
```typescript
|
|
@@ -2571,6 +2773,7 @@ const checkout = await client.setCheckoutCustomer(checkoutId, {
|
|
|
2571
2773
|
|
|
2572
2774
|
```typescript
|
|
2573
2775
|
const { checkout, rates } = await client.setShippingAddress(checkoutId, {
|
|
2776
|
+
email: 'customer@example.com', // REQUIRED, on every call
|
|
2574
2777
|
firstName: 'John',
|
|
2575
2778
|
lastName: 'Doe',
|
|
2576
2779
|
line1: '123 Main St',
|
|
@@ -2589,6 +2792,11 @@ const { checkout, rates } = await client.setShippingAddress(checkoutId, {
|
|
|
2589
2792
|
console.log(rates); // ShippingRate[]
|
|
2590
2793
|
```
|
|
2591
2794
|
|
|
2795
|
+
> **`email` is required, including for logged-in customers and including when
|
|
2796
|
+
> you already sent it to `setCheckoutCustomer`.** It is validated before any
|
|
2797
|
+
> service code runs, so the server never fills it in from the customer record;
|
|
2798
|
+
> omit it or send an empty string and the call fails with a 400.
|
|
2799
|
+
|
|
2592
2800
|
> **Always pass `placeId` when you use address autocomplete.** The server
|
|
2593
2801
|
> re-resolves it to the address's exact coordinates, which is how stores that
|
|
2594
2802
|
> draw their delivery areas on a map ("polygon" zones) decide whether they
|
|
@@ -2681,7 +2889,8 @@ const updatedCheckout = await client.setCheckoutCustomFields(checkoutId, {
|
|
|
2681
2889
|
**DATE / DATETIME fields with availability constraints**
|
|
2682
2890
|
|
|
2683
2891
|
A `DATE`/`DATETIME` field's `dateAvailability` (blocked weekdays, blocked specific
|
|
2684
|
-
dates, min/max date range,
|
|
2892
|
+
dates, min/max date range, the relative bounds `leadTimeMinutes` / `cutoffTime` /
|
|
2893
|
+
`maxDaysAhead`, and — for `DATETIME` — business hours + time
|
|
2685
2894
|
slots) is a merchant-configured restriction on which values the customer may
|
|
2686
2895
|
pick. Use `computeAvailableSlots()` / `getBusinessHoursForDate()` /
|
|
2687
2896
|
`isDateValueAllowed()` to drive your own date-picker/slot-picker UI — the SDK
|
|
@@ -2700,19 +2909,24 @@ const { timezone } = await client.getStoreInfo(); // IANA string, e.g. "Asia/Jer
|
|
|
2700
2909
|
const deliveryField = fields.find((f) => f.key === 'delivery_slot');
|
|
2701
2910
|
const availability = deliveryField?.dateAvailability;
|
|
2702
2911
|
|
|
2912
|
+
// The clock. Without it leadTimeMinutes/cutoffTime/maxDaysAhead are SKIPPED and
|
|
2913
|
+
// the picker offers days the server refuses; `now` defaults to this instant.
|
|
2914
|
+
const clock = { timezone };
|
|
2915
|
+
|
|
2703
2916
|
// Disable days on your calendar of choice. Note the SECOND condition: once
|
|
2704
2917
|
// businessHours has any entry it is an ALLOWLIST, so a weekday it doesn't
|
|
2705
2918
|
// mention is closed all day even though the calendar rules accept it.
|
|
2706
2919
|
const isDayDisabled = (ymd: string) =>
|
|
2707
|
-
!isCalendarDateAllowed(ymd, availability) ||
|
|
2920
|
+
!isCalendarDateAllowed(ymd, availability, clock) ||
|
|
2708
2921
|
(!!availability?.businessHours?.length &&
|
|
2709
|
-
getBusinessHoursForDate(availability, ymd).length === 0);
|
|
2922
|
+
getBusinessHoursForDate(availability, ymd, clock).length === 0);
|
|
2710
2923
|
|
|
2711
2924
|
// Once the customer picks a day, offer times. computeAvailableSlots() returns
|
|
2712
2925
|
// [] when the field has no slotDurationMinutes — that is NOT "day closed",
|
|
2713
|
-
// which is why the windows are checked separately.
|
|
2714
|
-
|
|
2715
|
-
const
|
|
2926
|
+
// which is why the windows are checked separately. A day with two windows
|
|
2927
|
+
// (mornings and evenings) yields both, in chronological order.
|
|
2928
|
+
const slots = computeAvailableSlots(availability, '2026-08-15', clock); // ["09:00", "09:30", ...]
|
|
2929
|
+
const windows = getBusinessHoursForDate(availability, '2026-08-15', clock); // [{ weekday, open, close }]
|
|
2716
2930
|
|
|
2717
2931
|
if (slots.length) {
|
|
2718
2932
|
// Render slot buttons; the submitted time must equal a slot start exactly.
|
|
@@ -2736,6 +2950,19 @@ What you read back is normalized, not the string you sent: `YYYY-MM-DD` for
|
|
|
2736
2950
|
`DATE`, an ISO-8601 UTC instant for `DATETIME`. Use `parseDateFieldValue()` if
|
|
2737
2951
|
you want to apply the exact same parse client-side before submitting.
|
|
2738
2952
|
|
|
2953
|
+
**Relative bounds.** `leadTimeMinutes` puts the earliest bookable moment at
|
|
2954
|
+
`now + leadTime`. `cutoffTime` ("HH:mm", store-local) pushes the earliest
|
|
2955
|
+
bookable DATE on by a further day once the store clock reaches it, which is how
|
|
2956
|
+
"order by 14:00 for tomorrow" is expressed. `maxDaysAhead` is a rolling ceiling
|
|
2957
|
+
counted from today. All three are re-resolved on every call, so unlike an
|
|
2958
|
+
absolute `minDate` they never go stale, and all three apply to plain `DATE`
|
|
2959
|
+
fields as well. They are accepted on **checkout** custom fields only: a product
|
|
2960
|
+
metafield and an order custom field are written by an admin rather than picked
|
|
2961
|
+
by a shopper, so there is no ordering moment to measure them from and the API
|
|
2962
|
+
rejects them there. `resolveRelativeBounds(availability, clock)` returns the
|
|
2963
|
+
concrete dates they currently mean, which is what to show a shopper who asked
|
|
2964
|
+
for something too soon.
|
|
2965
|
+
|
|
2739
2966
|
The backend independently re-validates every submitted value against the same
|
|
2740
2967
|
constraints at write time — this is a client-side UX aid, not the source of
|
|
2741
2968
|
enforcement.
|
|
@@ -2818,6 +3045,7 @@ The shipping flow involves setting an address and then selecting from available
|
|
|
2818
3045
|
```typescript
|
|
2819
3046
|
// Step 1: Set shipping address - this returns available rates
|
|
2820
3047
|
const { checkout, rates } = await client.setShippingAddress(checkoutId, {
|
|
3048
|
+
email: 'customer@example.com', // required
|
|
2821
3049
|
firstName: 'John',
|
|
2822
3050
|
lastName: 'Doe',
|
|
2823
3051
|
line1: '123 Main St',
|
|
@@ -3481,12 +3709,24 @@ if (intent.clientSdk?.renderType === 'sandbox') {
|
|
|
3481
3709
|
> characters" produces a 400 the shopper cannot explain, and render the server's
|
|
3482
3710
|
> message verbatim when one comes back.
|
|
3483
3711
|
|
|
3712
|
+
> **Birthday fields.** `birthMonth` (1-12) and `birthDay` (1-31) are optional,
|
|
3713
|
+
> month and day only, never a year. Send both or neither: one on its own is
|
|
3714
|
+
> rejected with HTTP 400, and so is a day the month does not have. When
|
|
3715
|
+
> `getStoreInfo().requireBirthday` is true the merchant made the birthday
|
|
3716
|
+
> mandatory on that sales channel and a register call without it fails with
|
|
3717
|
+
> HTTP 400. That flag only reaches sales-channel mode (`salesChannelId`); a
|
|
3718
|
+
> `storeId`-mode storefront never receives it and its register route never
|
|
3719
|
+
> enforces it.
|
|
3720
|
+
|
|
3484
3721
|
```typescript
|
|
3485
3722
|
const auth = await client.registerCustomer({
|
|
3486
3723
|
email: 'customer@example.com',
|
|
3487
3724
|
password: 'SecurePass123!',
|
|
3488
3725
|
firstName: 'John',
|
|
3489
3726
|
lastName: 'Doe',
|
|
3727
|
+
// Optional, unless getStoreInfo().requireBirthday is true. Both or neither.
|
|
3728
|
+
birthMonth: 4,
|
|
3729
|
+
birthDay: 17,
|
|
3490
3730
|
});
|
|
3491
3731
|
|
|
3492
3732
|
// Check if email verification is required
|
|
@@ -3557,6 +3797,12 @@ console.log(profile.firstName);
|
|
|
3557
3797
|
console.log(profile.email);
|
|
3558
3798
|
console.log(profile.addresses);
|
|
3559
3799
|
|
|
3800
|
+
// Birthday the customer saved: month and day only, never a year. Both fields
|
|
3801
|
+
// arrive together or neither does, so testing one is enough.
|
|
3802
|
+
if (profile.birthMonth && profile.birthDay) {
|
|
3803
|
+
console.log(`Birthday: ${profile.birthDay}/${profile.birthMonth}`);
|
|
3804
|
+
}
|
|
3805
|
+
|
|
3560
3806
|
// profile.role is a free-form segment the merchant sets from the dashboard
|
|
3561
3807
|
// (e.g. "wholesale", "vip") — not customer-editable. Use it to gate custom
|
|
3562
3808
|
// storefront features/UI: wholesale pricing, a VIP section, etc.
|
|
@@ -3565,6 +3811,30 @@ if (profile.role === 'wholesale') {
|
|
|
3565
3811
|
}
|
|
3566
3812
|
```
|
|
3567
3813
|
|
|
3814
|
+
#### Update Customer Profile
|
|
3815
|
+
|
|
3816
|
+
Storefront or vibe-coded mode, requires `customerToken`. The call returns the
|
|
3817
|
+
saved `CustomerProfile`, so re-render the form from the response instead of
|
|
3818
|
+
from what you sent. `email` and `role` are not customer-editable and are not
|
|
3819
|
+
accepted here.
|
|
3820
|
+
|
|
3821
|
+
```typescript
|
|
3822
|
+
const updated = await client.updateMyProfile({
|
|
3823
|
+
firstName: 'John',
|
|
3824
|
+
lastName: 'Doe',
|
|
3825
|
+
phone: '+15550100',
|
|
3826
|
+
acceptsMarketing: true,
|
|
3827
|
+
// Birthday: month and day only, never a year. Send both or neither, and the
|
|
3828
|
+
// day has to exist in the month (day 31 in February is rejected with 400).
|
|
3829
|
+
birthMonth: 4,
|
|
3830
|
+
birthDay: 17,
|
|
3831
|
+
});
|
|
3832
|
+
|
|
3833
|
+
// The saved birthday comes back on the response and on getMyProfile(), so the
|
|
3834
|
+
// profile form shows what the customer stored instead of two empty fields.
|
|
3835
|
+
console.log(updated.birthMonth, updated.birthDay);
|
|
3836
|
+
```
|
|
3837
|
+
|
|
3568
3838
|
#### Get Customer Orders
|
|
3569
3839
|
|
|
3570
3840
|
```typescript
|
|
@@ -3646,10 +3916,15 @@ await client.registerCustomer({ email, password, referralCode: refFromQuery });
|
|
|
3646
3916
|
// bonus (held through the program's pending window, like order points).
|
|
3647
3917
|
```
|
|
3648
3918
|
|
|
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).
|
|
3919
|
+
Birthday gifts need no SDK calls beyond profile data: save the customer's
|
|
3920
|
+
`birthMonth`/`birthDay` (1-12 / 1-31, month and day only, never a year) via
|
|
3921
|
+
`updateMyProfile()` and the platform emails a one-time gift coupon ahead of
|
|
3922
|
+
their birthday automatically (when the store has it enabled). Both values come
|
|
3923
|
+
back on `getMyProfile()`, on `getCheckoutPrefillData()` and on the `Customer`
|
|
3924
|
+
read types, so a profile form renders the birthday the customer already gave
|
|
3925
|
+
you rather than an empty pair of fields. `registerCustomer()` accepts the same
|
|
3926
|
+
two fields, and a channel with `requireBirthday` turned on insists on them at
|
|
3927
|
+
signup.
|
|
3653
3928
|
|
|
3654
3929
|
#### Paid Loyalty Membership
|
|
3655
3930
|
|
|
@@ -3741,6 +4016,8 @@ const auth = await client.registerCustomer({
|
|
|
3741
4016
|
email: 'customer@example.com',
|
|
3742
4017
|
password: 'SecurePass123!',
|
|
3743
4018
|
firstName: 'John',
|
|
4019
|
+
// Add birthMonth + birthDay here too when getStoreInfo().requireBirthday is
|
|
4020
|
+
// true, or this call fails with HTTP 400 before any email is sent.
|
|
3744
4021
|
});
|
|
3745
4022
|
|
|
3746
4023
|
if (auth.requiresVerification) {
|
|
@@ -5146,12 +5423,17 @@ is what stops an API key from escalating its own team permissions. Pointing the
|
|
|
5146
5423
|
the correct path would earn a `403` instead of a `404`. Invite, re-scope and remove
|
|
5147
5424
|
members in the dashboard.
|
|
5148
5425
|
|
|
5149
|
-
> **The older account-level methods are not
|
|
5426
|
+
> **The older account-level methods are not a substitute for this.** `getTeamMembers`,
|
|
5150
5427
|
> `getTeamInvitations`, `inviteTeamMember`, `resendTeamInvitation`, `revokeTeamInvitation`,
|
|
5151
5428
|
> `updateTeamMemberRole` and `removeTeamMember` do still reach `/api/v1/team/…` — but they
|
|
5152
|
-
> manage the **account** team, not a store's
|
|
5153
|
-
>
|
|
5154
|
-
>
|
|
5429
|
+
> manage the **account** team, not a store's. They will not invite anyone to a store or
|
|
5430
|
+
> scope a member to a sales channel; only the dashboard does that.
|
|
5431
|
+
>
|
|
5432
|
+
> **For the account team, they remain the supported call.** All seven are tagged
|
|
5433
|
+
> `@deprecated`, which records an intent to retire them — not a migration you can perform
|
|
5434
|
+
> today. There is no API-key replacement: the store-level methods named above are
|
|
5435
|
+
> dashboard-only. Keep using these until an API-key route ships, and expect the tag to
|
|
5436
|
+
> outlive this note.
|
|
5155
5437
|
|
|
5156
5438
|
### Email Settings & Templates
|
|
5157
5439
|
|
|
@@ -5314,6 +5596,56 @@ await client.detachModifierGroup(storeId, productId, attachment.id);
|
|
|
5314
5596
|
|
|
5315
5597
|
`null` on an override means "inherit from the group default"; any non-null value (including `0` or `false`) wins. `modifierGroupId` and `variantId` are immutable on `updateAttachment` — to swap a group, detach and re-attach.
|
|
5316
5598
|
|
|
5599
|
+
### Review Moderation
|
|
5600
|
+
|
|
5601
|
+
Five admin methods for moderating customer reviews and the photos attached to
|
|
5602
|
+
them. Listing needs an API key with `reviews:read`; hiding and showing need
|
|
5603
|
+
`reviews:write`. The storefront methods are covered under [Product Reviews](#product-reviews).
|
|
5604
|
+
|
|
5605
|
+
`storeId` is optional on every call: pass it when the key can reach more than
|
|
5606
|
+
one store.
|
|
5607
|
+
|
|
5608
|
+
```typescript
|
|
5609
|
+
// Every review on the product, including the hidden ones the storefront omits.
|
|
5610
|
+
// visibility: 'visible' | 'hidden' | 'all' (default 'all').
|
|
5611
|
+
const { data, meta } = await client.adminListProductReviews('prod_123', {
|
|
5612
|
+
page: 1,
|
|
5613
|
+
limit: 20,
|
|
5614
|
+
visibility: 'hidden',
|
|
5615
|
+
storeId, // optional
|
|
5616
|
+
});
|
|
5617
|
+
|
|
5618
|
+
// ProductReviewAdmin carries the PII the storefront type does not:
|
|
5619
|
+
// customerId, authorEmail, orderId, updatedAt, hiddenAt, plus `images` typed as
|
|
5620
|
+
// ProductReviewImageAdmin[] (assetKey, approvedAt, hiddenAt, createdAt).
|
|
5621
|
+
data.forEach((review) => {
|
|
5622
|
+
console.log(review.authorEmail, review.orderId, review.hiddenAt);
|
|
5623
|
+
});
|
|
5624
|
+
```
|
|
5625
|
+
|
|
5626
|
+
```typescript
|
|
5627
|
+
// Hide a whole review, then put it back. Hiding stamps hiddenAt; showing clears it.
|
|
5628
|
+
await client.hideProductReview('rev_123', storeId);
|
|
5629
|
+
await client.showProductReview('rev_123', storeId);
|
|
5630
|
+
```
|
|
5631
|
+
|
|
5632
|
+
```typescript
|
|
5633
|
+
// Photo-level moderation: take down ONE photo and leave the review, its rating
|
|
5634
|
+
// and its other photos live.
|
|
5635
|
+
await client.hideProductReviewImage('revimg_123', storeId);
|
|
5636
|
+
|
|
5637
|
+
// Showing a photo is ALSO the approve action. A photo that has never been
|
|
5638
|
+
// approved carries `approvedAt: null`, and showing it stamps one, so stores that
|
|
5639
|
+
// turned on review-photo approval need no separate verb.
|
|
5640
|
+
await client.showProductReviewImage('revimg_123', storeId);
|
|
5641
|
+
```
|
|
5642
|
+
|
|
5643
|
+
> **Reviews publish immediately.** There is no pending state for the review text
|
|
5644
|
+
> itself, so a moderation queue built on "approve each review" has nothing to
|
|
5645
|
+
> read. Photos are the only part that can wait on the merchant, and only on
|
|
5646
|
+
> stores that turned approval on. Poll `adminListProductReviews(productId, { visibility: 'all' })`
|
|
5647
|
+
> and treat any image with `approvedAt: null` as the queue.
|
|
5648
|
+
|
|
5317
5649
|
---
|
|
5318
5650
|
|
|
5319
5651
|
## Complete Page Examples
|
|
@@ -5726,12 +6058,14 @@ export default function CheckoutPage() {
|
|
|
5726
6058
|
const [checkout, setCheckout] = useState<Checkout | null>(null);
|
|
5727
6059
|
const [shippingRates, setShippingRates] = useState<ShippingRate[]>([]);
|
|
5728
6060
|
const [selectedRate, setSelectedRate] = useState<string | null>(null);
|
|
6061
|
+
// Two phases: collect the address, then let the shopper pick a delivery option.
|
|
6062
|
+
const [step, setStep] = useState<'address' | 'shipping'>('address');
|
|
5729
6063
|
const customerLoggedIn = isLoggedIn();
|
|
5730
6064
|
|
|
5731
|
-
// Form state
|
|
5732
|
-
|
|
6065
|
+
// Form state. `email` lives here because setShippingAddress requires it on
|
|
6066
|
+
// every call, logged-in shoppers included.
|
|
5733
6067
|
const [shippingAddress, setShippingAddress] = useState({
|
|
5734
|
-
firstName: '', lastName: '', line1: '', city: '', postalCode: '', country: 'US'
|
|
6068
|
+
email: '', firstName: '', lastName: '', line1: '', city: '', postalCode: '', country: 'US'
|
|
5735
6069
|
});
|
|
5736
6070
|
|
|
5737
6071
|
useEffect(() => {
|
|
@@ -5761,7 +6095,8 @@ export default function CheckoutPage() {
|
|
|
5761
6095
|
initCheckout();
|
|
5762
6096
|
}, []);
|
|
5763
6097
|
|
|
5764
|
-
|
|
6098
|
+
// Phase 1: save the customer and the address, then show the rate picker.
|
|
6099
|
+
const handleAddressSubmit = async (e: React.FormEvent) => {
|
|
5765
6100
|
e.preventDefault();
|
|
5766
6101
|
if (!checkout) return;
|
|
5767
6102
|
setSubmitting(true);
|
|
@@ -5769,19 +6104,33 @@ export default function CheckoutPage() {
|
|
|
5769
6104
|
try {
|
|
5770
6105
|
// 1. Set customer info
|
|
5771
6106
|
await client.setCheckoutCustomer(checkout.id, {
|
|
5772
|
-
email,
|
|
6107
|
+
email: shippingAddress.email,
|
|
5773
6108
|
firstName: shippingAddress.firstName,
|
|
5774
6109
|
lastName: shippingAddress.lastName,
|
|
5775
6110
|
});
|
|
5776
6111
|
|
|
5777
|
-
// 2. Set shipping address
|
|
5778
|
-
|
|
6112
|
+
// 2. Set shipping address. The same call returns the rates for that
|
|
6113
|
+
// address, so store them instead of fetching a second time.
|
|
6114
|
+
const { rates } = await client.setShippingAddress(checkout.id, shippingAddress);
|
|
6115
|
+
setShippingRates(rates);
|
|
6116
|
+
setSelectedRate(rates[0]?.id ?? null); // preselect, the shopper can change it
|
|
6117
|
+
setStep('shipping');
|
|
6118
|
+
} catch (error) {
|
|
6119
|
+
console.error('Could not price shipping:', error);
|
|
6120
|
+
alert('We could not load shipping options for that address.');
|
|
6121
|
+
} finally {
|
|
6122
|
+
setSubmitting(false);
|
|
6123
|
+
}
|
|
6124
|
+
};
|
|
5779
6125
|
|
|
5780
|
-
|
|
5781
|
-
|
|
5782
|
-
|
|
5783
|
-
|
|
5784
|
-
|
|
6126
|
+
// Phase 2: the shopper has actually chosen a rate, so persist it and complete.
|
|
6127
|
+
const handlePlaceOrder = async () => {
|
|
6128
|
+
if (!checkout || !selectedRate) return;
|
|
6129
|
+
setSubmitting(true);
|
|
6130
|
+
|
|
6131
|
+
try {
|
|
6132
|
+
// 3. Persist the chosen rate
|
|
6133
|
+
await client.selectShippingMethod(checkout.id, selectedRate);
|
|
5785
6134
|
|
|
5786
6135
|
// 4. Complete checkout
|
|
5787
6136
|
const { orderId } = await client.completeCheckout(checkout.id);
|
|
@@ -5800,28 +6149,42 @@ export default function CheckoutPage() {
|
|
|
5800
6149
|
|
|
5801
6150
|
if (loading) return <div>Loading checkout...</div>;
|
|
5802
6151
|
|
|
6152
|
+
if (step === 'shipping') {
|
|
6153
|
+
return (
|
|
6154
|
+
<div>
|
|
6155
|
+
<h2>Delivery</h2>
|
|
6156
|
+
{shippingRates.length === 0 ? (
|
|
6157
|
+
<p>We cannot ship to that address. Please go back and edit it.</p>
|
|
6158
|
+
) : (
|
|
6159
|
+
<select value={selectedRate || ''} onChange={(e) => setSelectedRate(e.target.value)}>
|
|
6160
|
+
{shippingRates.map((rate) => (
|
|
6161
|
+
// speedTier for live carrier rates; the merchant's own name for zone rates
|
|
6162
|
+
<option key={rate.id} value={rate.id}>{rate.speedTier ? TIER_LABELS[rate.speedTier] : rate.name} - ${rate.price}</option>
|
|
6163
|
+
))}
|
|
6164
|
+
</select>
|
|
6165
|
+
)}
|
|
6166
|
+
|
|
6167
|
+
<button type="button" onClick={() => setStep('address')} disabled={submitting}>Edit address</button>
|
|
6168
|
+
<button type="button" onClick={handlePlaceOrder} disabled={submitting || !selectedRate}>
|
|
6169
|
+
{submitting ? 'Processing...' : 'Place Order'}
|
|
6170
|
+
</button>
|
|
6171
|
+
</div>
|
|
6172
|
+
);
|
|
6173
|
+
}
|
|
6174
|
+
|
|
5803
6175
|
return (
|
|
5804
|
-
<form onSubmit={
|
|
5805
|
-
{
|
|
5806
|
-
|
|
5807
|
-
)}
|
|
6176
|
+
<form onSubmit={handleAddressSubmit}>
|
|
6177
|
+
{/* Always collect the email. setShippingAddress rejects a blank one, and
|
|
6178
|
+
it is validated before any server-side lookup of the logged-in profile. */}
|
|
6179
|
+
<input type="email" value={shippingAddress.email} onChange={(e) => setShippingAddress({...shippingAddress, email: e.target.value})} placeholder="Email" required />
|
|
5808
6180
|
<input value={shippingAddress.firstName} onChange={(e) => setShippingAddress({...shippingAddress, firstName: e.target.value})} placeholder="First Name" required />
|
|
5809
6181
|
<input value={shippingAddress.lastName} onChange={(e) => setShippingAddress({...shippingAddress, lastName: e.target.value})} placeholder="Last Name" required />
|
|
5810
6182
|
<input value={shippingAddress.line1} onChange={(e) => setShippingAddress({...shippingAddress, line1: e.target.value})} placeholder="Address" required />
|
|
5811
6183
|
<input value={shippingAddress.city} onChange={(e) => setShippingAddress({...shippingAddress, city: e.target.value})} placeholder="City" required />
|
|
5812
6184
|
<input value={shippingAddress.postalCode} onChange={(e) => setShippingAddress({...shippingAddress, postalCode: e.target.value})} placeholder="Postal Code" required />
|
|
5813
6185
|
|
|
5814
|
-
{shippingRates.length > 0 && (
|
|
5815
|
-
<select value={selectedRate || ''} onChange={(e) => setSelectedRate(e.target.value)}>
|
|
5816
|
-
{shippingRates.map((rate) => (
|
|
5817
|
-
// speedTier for live carrier rates; the merchant's own name for zone rates
|
|
5818
|
-
<option key={rate.id} value={rate.id}>{rate.speedTier ? TIER_LABELS[rate.speedTier] : rate.name} - ${rate.price}</option>
|
|
5819
|
-
))}
|
|
5820
|
-
</select>
|
|
5821
|
-
)}
|
|
5822
|
-
|
|
5823
6186
|
<button type="submit" disabled={submitting}>
|
|
5824
|
-
{submitting ? '
|
|
6187
|
+
{submitting ? 'Loading delivery options...' : 'Continue to delivery'}
|
|
5825
6188
|
</button>
|
|
5826
6189
|
</form>
|
|
5827
6190
|
);
|
|
@@ -5831,6 +6194,9 @@ export default function CheckoutPage() {
|
|
|
5831
6194
|
> **Key Points:**
|
|
5832
6195
|
>
|
|
5833
6196
|
> - Both guests and logged-in users go through `createCheckout()` → `completeCheckout()`
|
|
6197
|
+
> - `setShippingAddress()` returns the rates for the address it just saved. Put them in state; a separate `getShippingRates()` call is not needed.
|
|
6198
|
+
> - Split the page into two phases. Fetching rates and completing the order in one submit means the shopper never gets to choose, and you silently charge whichever rate came back first.
|
|
6199
|
+
> - `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.
|
|
5834
6200
|
> - Guest session cart is created automatically by `smart*` methods
|
|
5835
6201
|
> - Call `client.onCheckoutComplete()` after successful payment to clear the session cart
|
|
5836
6202
|
> - Call `client.syncCartOnLogin()` when a user logs in to merge their guest cart
|
|
@@ -5900,6 +6266,7 @@ export default function CheckoutPage() {
|
|
|
5900
6266
|
setSubmitting(true);
|
|
5901
6267
|
try {
|
|
5902
6268
|
const { rates } = await client.setShippingAddress(checkout.id, {
|
|
6269
|
+
email, // required on every call, even after setCheckoutCustomer
|
|
5903
6270
|
firstName, lastName,
|
|
5904
6271
|
line1: address,
|
|
5905
6272
|
city, postalCode, country,
|
|
@@ -6082,6 +6449,8 @@ export default function RegisterPage() {
|
|
|
6082
6449
|
setLoading(true);
|
|
6083
6450
|
setError('');
|
|
6084
6451
|
try {
|
|
6452
|
+
// Add birthMonth + birthDay to this call (both, never a year) when
|
|
6453
|
+
// getStoreInfo().requireBirthday is true for the channel.
|
|
6085
6454
|
const auth = await client.registerCustomer({ email, password, firstName, lastName });
|
|
6086
6455
|
|
|
6087
6456
|
// Check if email verification is required
|
|
@@ -6183,6 +6552,9 @@ export default function AccountPage() {
|
|
|
6183
6552
|
<h2 className="text-xl font-bold mb-4">Profile</h2>
|
|
6184
6553
|
<p><strong>Name:</strong> {profile.firstName} {profile.lastName}</p>
|
|
6185
6554
|
<p><strong>Email:</strong> {profile.email}</p>
|
|
6555
|
+
{profile.birthMonth && profile.birthDay && (
|
|
6556
|
+
<p><strong>Birthday:</strong> {profile.birthDay}/{profile.birthMonth}</p>
|
|
6557
|
+
)}
|
|
6186
6558
|
</div>
|
|
6187
6559
|
|
|
6188
6560
|
<div className="border rounded p-6">
|
|
@@ -6304,6 +6676,89 @@ const forms = await brainerce.contactForms.list();
|
|
|
6304
6676
|
|
|
6305
6677
|
**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
6678
|
|
|
6679
|
+
**A form keyed `newsletter` is still an inquiry.** The key is a label, not a behaviour: the submission files a message and never touches marketing consent, so that address can never receive a campaign. For a mailing list, use [Newsletter Signup](#newsletter-signup-marketing-opt-in).
|
|
6680
|
+
|
|
6681
|
+
---
|
|
6682
|
+
|
|
6683
|
+
## Newsletter Signup (marketing opt-in)
|
|
6684
|
+
|
|
6685
|
+
**SDK >= 1.60.** The email-capture popup, the footer subscribe bar, the exit-intent modal.
|
|
6686
|
+
|
|
6687
|
+
```typescript
|
|
6688
|
+
await brainerce.marketing.subscribe({
|
|
6689
|
+
email: 'jane@example.com',
|
|
6690
|
+
locale: 'he', // language of the confirmation email
|
|
6691
|
+
source: 'popup', // free-form, for the merchant's reporting
|
|
6692
|
+
honeypot: hiddenFieldValue, // must be empty
|
|
6693
|
+
});
|
|
6694
|
+
// → { ok: true }
|
|
6695
|
+
```
|
|
6696
|
+
|
|
6697
|
+
Also accepts `firstName`, `lastName`, and `sourceMetadata` (referrer, UTM params, the page the popup fired on).
|
|
6698
|
+
|
|
6699
|
+
**⛔ It does not subscribe anyone.** The contact is created and mailed a confirmation link; the address is unmailable — and invisible to every campaign audience — until the recipient clicks it. Render **"Check your email to confirm — including your spam folder"** on success, never "You're subscribed". The spam-folder half matters: a confirmation filtered there is the commonest reason a signup never converts, and the 24-hour resend cooldown means no second copy arrives. Single opt-in is not available: without the click, anyone could subscribe anyone else's address.
|
|
6700
|
+
|
|
6701
|
+
**⛔ The response carries no information.** `{ ok: true }` is returned identically for a brand-new address, one that confirmed months ago, one inside its 24-hour resend cooldown, and one suppressed after a hard bounce — otherwise the form would become a way to test who shops at this store. Show one message for every success; there is no branch to write.
|
|
6702
|
+
|
|
6703
|
+
**Rate limit:** 3 requests per 60 seconds per IP, plus one confirmation email per address per store per 24 hours. A submission inside that cooldown still returns `{ ok: true }` and silently sends nothing — do not treat it as a failure or retry it.
|
|
6704
|
+
|
|
6705
|
+
**Locale:** pass it on a multi-language storefront, or the confirmation email falls back to the store's language. `he` and `en` are written; anything else gets English.
|
|
6706
|
+
|
|
6707
|
+
**No discount code is minted.** For a "10% off your first order" popup, the merchant creates one coupon with the `customer_first_order` condition and you display that fixed code after a successful call.
|
|
6708
|
+
|
|
6709
|
+
The contact appears at `Customers` in the dashboard immediately, with **Accepts marketing** off; it flips on at confirmation. It is an ordinary guest customer record — no password, no account — and is the same row if that person later registers or checks out.
|
|
6710
|
+
|
|
6711
|
+
---
|
|
6712
|
+
|
|
6713
|
+
## Back-in-Stock Alerts
|
|
6714
|
+
|
|
6715
|
+
**SDK >= 1.61.** The "email me when this is back" button on a sold-out product.
|
|
6716
|
+
|
|
6717
|
+
```typescript
|
|
6718
|
+
await brainerce.stockAlerts.subscribe({
|
|
6719
|
+
email: 'jane@example.com',
|
|
6720
|
+
productId: product.id,
|
|
6721
|
+
variantId: selectedVariant.id, // pass on ANY product with variants
|
|
6722
|
+
locale: 'he', // language of the alert email
|
|
6723
|
+
honeypot: hiddenFieldValue, // must be empty
|
|
6724
|
+
});
|
|
6725
|
+
// → { ok: true }
|
|
6726
|
+
```
|
|
6727
|
+
|
|
6728
|
+
**⛔ It is not a subscription.** One email, about one item, carrying a link that stops it. No customer account is created and no marketing consent is granted. Label the button **"Email me when it's back"**, never "Subscribe" — and because it grants no consent, never hide it from a shopper who unsubscribed from your marketing.
|
|
6729
|
+
|
|
6730
|
+
**⛔ Render it only when `getStoreInfo().stockAlertsEnabled !== false`, the item is out of stock, AND it cannot be backordered.** Requests for anything else — a storefront whose merchant switched the feature off, an in-stock item, a backorderable one, an untracked one, an unknown product id — are silently ignored, so a button in the wrong place looks like it worked and does nothing.
|
|
6731
|
+
|
|
6732
|
+
```typescript
|
|
6733
|
+
const store = await brainerce.getStoreInfo();
|
|
6734
|
+
// `inv` is the SELECTED VARIANT's inventory when there is one, else the product's.
|
|
6735
|
+
const inv = selectedVariant?.inventory ?? product.inventory;
|
|
6736
|
+
|
|
6737
|
+
const canOfferStockAlert =
|
|
6738
|
+
store.stockAlertsEnabled !== false &&
|
|
6739
|
+
inv?.trackingMode === 'TRACKED' &&
|
|
6740
|
+
!inv.canPurchase &&
|
|
6741
|
+
(inv.backorderMode ?? 'NONE') === 'NONE';
|
|
6742
|
+
```
|
|
6743
|
+
|
|
6744
|
+
`backorderMode` is on `InventoryInfo` from SDK 1.61; older backends omit it, so treat `undefined` as `'NONE'`.
|
|
6745
|
+
|
|
6746
|
+
The merchant controls the switch — and how many people are emailed per unit restocked — under **Channel settings → Inventory**, alongside the low-stock warning.
|
|
6747
|
+
|
|
6748
|
+
**⛔ Pass `variantId` on every variable product.** Without it the alert waits on the product as a whole, so a shopper who wanted the medium is mailed when the small returns and arrives to find their size still gone.
|
|
6749
|
+
|
|
6750
|
+
**⛔ The response carries no information.** `{ ok: true }` is returned identically for a new request, a duplicate, an unknown product, an item already in stock, and an address suppressed after a hard bounce — otherwise the button would become a way to read the store's stock levels. Show one message for every success; there is no branch to write.
|
|
6751
|
+
|
|
6752
|
+
**Sending is not immediate, and not to everyone.** Availability is `total - reserved`, so an expiring cart briefly lifts a sold-out item above zero; the alert waits for stock to hold for a few minutes, then goes out in waves sized to the units that came back (500 waiting and 3 units restocked is roughly 9 emails, oldest request first). A shopper can therefore sit through a restock without hearing, so never promise "you'll be the first to know".
|
|
6753
|
+
|
|
6754
|
+
**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.
|
|
6755
|
+
|
|
6756
|
+
**Locale:** pass it on a multi-language storefront, or the alert falls back to the store's language. `he` and `en` are written; anything else gets English.
|
|
6757
|
+
|
|
6758
|
+
**What it does not do:** no SMS or WhatsApp, no price-drop alerts, and no merchant-editable template — the body is fixed so it can never start carrying a discount code, which would turn a transactional message into a marketing one needing an unsubscribe link it does not have.
|
|
6759
|
+
|
|
6760
|
+
The merchant reads the demand at `Products → Back-in-Stock Waitlist`: products ranked by how many people are waiting, with the addresses behind each number. There is no way to mail those people anything else from there, by design.
|
|
6761
|
+
|
|
6307
6762
|
---
|
|
6308
6763
|
|
|
6309
6764
|
## Storefront Bot (AI chat widget)
|
|
@@ -6383,16 +6838,62 @@ export async function POST(req: Request) {
|
|
|
6383
6838
|
|
|
6384
6839
|
### Webhook Events
|
|
6385
6840
|
|
|
6386
|
-
|
|
6387
|
-
|
|
6388
|
-
|
|
6389
|
-
|
|
6390
|
-
|
|
|
6391
|
-
|
|
|
6392
|
-
| `order.created`
|
|
6393
|
-
| `order.updated`
|
|
6394
|
-
| `
|
|
6395
|
-
| `
|
|
6841
|
+
**These 21 event types are what a subscription can actually register.** The
|
|
6842
|
+
backend validates the `events` array on create against exactly this list, so
|
|
6843
|
+
anything outside it is rejected rather than silently accepted.
|
|
6844
|
+
|
|
6845
|
+
| Event | Description |
|
|
6846
|
+
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
6847
|
+
| `order.created` | New order placed (any payment status) |
|
|
6848
|
+
| `order.updated` | Order metadata changed (status, address, items) |
|
|
6849
|
+
| `order.paid` | Order is paid — 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 |
|
|
6850
|
+
| `order.fulfilled` | All items marked shipped/delivered |
|
|
6851
|
+
| `order.cancelled` | Order cancelled (by merchant or customer) |
|
|
6852
|
+
| `order.refunded` | Order fully or partially refunded |
|
|
6853
|
+
| `customer.created` | New customer account created |
|
|
6854
|
+
| `customer.updated` | Customer profile or contact details changed |
|
|
6855
|
+
| `customer.deleted` | Customer account deleted |
|
|
6856
|
+
| `product.created` | New product added to catalog |
|
|
6857
|
+
| `product.updated` | Product attributes, variants, or pricing changed |
|
|
6858
|
+
| `product.deleted` | Product removed from catalog |
|
|
6859
|
+
| `inventory.updated` | Stock level changed (any reason) |
|
|
6860
|
+
| `inventory.low` | Stock fell below the low-stock threshold |
|
|
6861
|
+
| `checkout.completed` | Checkout completed (synonym of `order.created` for now) |
|
|
6862
|
+
| `checkout.abandoned` | Cart inactive for 1+ hours with no completion |
|
|
6863
|
+
| `payment.succeeded` | Payment provider confirmed funds captured |
|
|
6864
|
+
| `payment.failed` | Payment provider rejected the transaction |
|
|
6865
|
+
| `payment.refunded` | Refund posted to the customer |
|
|
6866
|
+
| `blog.post.published` | Post went live (manual, scheduled, or SEO Autopilot) |
|
|
6867
|
+
| `blog.post.updated` | Published post content changed |
|
|
6868
|
+
|
|
6869
|
+
Payload shapes for each are in the
|
|
6870
|
+
[Event Catalogue](https://brainerce.com/docs/webhooks/events).
|
|
6871
|
+
|
|
6872
|
+
`customer.created` now fires for **shopper self-signup on your storefront**, not
|
|
6873
|
+
just merchant-created customers, so a storefront that registers customers will
|
|
6874
|
+
start seeing it.
|
|
6875
|
+
|
|
6876
|
+
> **⚠️ The `WebhookEventType` type does not match this table yet — in both
|
|
6877
|
+
> directions.** Treat the table, not the type, as the truth about what you can
|
|
6878
|
+
> subscribe to.
|
|
6879
|
+
>
|
|
6880
|
+
> **14 subscribable events are missing from the type:** `order.paid`,
|
|
6881
|
+
> `order.fulfilled`, `order.cancelled`, `order.refunded`, `customer.created`,
|
|
6882
|
+
> `customer.updated`, `customer.deleted`, `inventory.low`, `checkout.abandoned`,
|
|
6883
|
+
> `payment.succeeded`, `payment.failed`, `payment.refunded`,
|
|
6884
|
+
> `blog.post.published`, `blog.post.updated`. So
|
|
6885
|
+
> `isWebhookEventType(event, 'customer.created')` and a
|
|
6886
|
+
> `createWebhookHandler({ 'order.paid': … })` key **fail to compile**, even
|
|
6887
|
+
> though both deliver correctly at runtime. Cast the name
|
|
6888
|
+
> (`'customer.created' as WebhookEventType`) or read `event.event` as a
|
|
6889
|
+
> `string` and switch on it yourself. Do not conclude the event does not exist.
|
|
6890
|
+
>
|
|
6891
|
+
> **8 names in the type cannot be subscribed to at all:** `coupon.created`,
|
|
6892
|
+
> `coupon.updated`, `coupon.deleted`, `cart.created`, `cart.updated`,
|
|
6893
|
+
> `cart.abandoned`, `checkout.started`, `checkout.failed`. These compile
|
|
6894
|
+
> cleanly and then fail at subscription time. `cart.abandoned` in particular
|
|
6895
|
+
> was listed as a supported event here for a long time — use
|
|
6896
|
+
> `checkout.abandoned` instead.
|
|
6396
6897
|
|
|
6397
6898
|
---
|
|
6398
6899
|
|