brainerce 1.51.0 → 1.53.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 +350 -47
- package/dist/bot/bootstrap.global.js +10 -10
- package/dist/bot/index.js +5 -2
- package/dist/bot/index.mjs +5 -2
- package/dist/index.d.mts +494 -14
- package/dist/index.d.ts +494 -14
- package/dist/index.js +434 -31
- package/dist/index.mjs +432 -31
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -94,9 +94,9 @@ Violating any of these causes production incidents or broken orders. Read them b
|
|
|
94
94
|
|
|
95
95
|
### Token handling
|
|
96
96
|
|
|
97
|
-
- Customer auth tokens (`result.token` from `loginCustomer`/`registerCustomer`) should be passed to `client.setCustomerToken(token)`. The SDK stores session state internally.
|
|
97
|
+
- 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 — 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.
|
|
98
98
|
- NEVER put the admin API key (`brainerce_*`) in client code. It is a server-only secret.
|
|
99
|
-
- OAuth callbacks arrive with a one-time `auth_code` URL param. Call `client.exchangeOAuthCode(authCode)` to swap it for the JWT
|
|
99
|
+
- OAuth callbacks arrive with a one-time `auth_code` URL param. Call `client.exchangeOAuthCode(authCode)` to swap it for the JWT, apply via `setCustomerToken`, then call `client.syncCartOnLogin()` to claim the guest cart. (The legacy `?token=` URL param is still emitted for backward compatibility but will be removed in the next major release.)
|
|
100
100
|
|
|
101
101
|
### i18n
|
|
102
102
|
|
|
@@ -142,6 +142,9 @@ These sequences are non-negotiable. The order of SDK calls matters.
|
|
|
142
142
|
```ts
|
|
143
143
|
await client.selectShippingMethod(checkoutId, rateId);
|
|
144
144
|
```
|
|
145
|
+
Label each rate with `rate.speedTier` (`'cheapest' | 'balanced' | 'fastest'`) and
|
|
146
|
+
`rate.estimatedDays` — **not** `rate.name`, which for live carrier rates is the
|
|
147
|
+
carrier's own service code. Manual zone rates carry no `speedTier`; use their `name`.
|
|
145
148
|
4. Fetch available payment providers:
|
|
146
149
|
```ts
|
|
147
150
|
const providers = await client.getPaymentProviders();
|
|
@@ -164,9 +167,9 @@ These sequences are non-negotiable. The order of SDK calls matters.
|
|
|
164
167
|
```
|
|
165
168
|
3. Branch on `result.requiresVerification`:
|
|
166
169
|
- `true` → store token temporarily, route to verify-email UI (do NOT set token yet)
|
|
167
|
-
- `false` → `client.setCustomerToken(result.token)`, route to account
|
|
170
|
+
- `false` → `client.setCustomerToken(result.token)`, then `await client.syncCartOnLogin()`, route to account
|
|
168
171
|
4. On verify-email: collect 6-digit code → `client.verifyEmail(code)`. Offer resend via `client.resendVerificationEmail()`.
|
|
169
|
-
5. After `verifyEmail` resolves: `client.setCustomerToken(result.token)`, route to account.
|
|
172
|
+
5. After `verifyEmail` resolves: `client.setCustomerToken(result.token)`, then `await client.syncCartOnLogin()`, route to account.
|
|
170
173
|
|
|
171
174
|
> Build the verify-email step even if verification is currently disabled — it auto-hides.
|
|
172
175
|
|
|
@@ -179,7 +182,7 @@ These sequences are non-negotiable. The order of SDK calls matters.
|
|
|
179
182
|
```
|
|
180
183
|
3. Branch on `result.requiresVerification`:
|
|
181
184
|
- `true` → route to verify-email
|
|
182
|
-
- `false` → `client.setCustomerToken(result.token)`, route to previous page or account
|
|
185
|
+
- `false` → `client.setCustomerToken(result.token)`, then `await client.syncCartOnLogin()`, route to previous page or account
|
|
183
186
|
4. Always offer OAuth buttons from `client.getAvailableOAuthProviders()` — render the region even when empty, it auto-populates when a provider is enabled.
|
|
184
187
|
5. Render specific errors (bad credentials, rate limited, disabled) — never swallow them.
|
|
185
188
|
|
|
@@ -230,11 +233,26 @@ the credential, no customer token needed.
|
|
|
230
233
|
const code = params.get('auth_code');
|
|
231
234
|
if (code) {
|
|
232
235
|
const result = await client.exchangeOAuthCode(code);
|
|
233
|
-
client.setCustomerToken(result.token);
|
|
236
|
+
client.setCustomerToken(result.token);
|
|
237
|
+
await client.syncCartOnLogin(); // REQUIRED — claims the guest cart
|
|
238
|
+
// then redirect to account
|
|
234
239
|
}
|
|
235
240
|
```
|
|
236
241
|
The legacy `?token=` URL param is still emitted for backward compatibility but will be removed in the next major release — migrate to `auth_code` now.
|
|
237
|
-
4. On `
|
|
242
|
+
4. On failure the browser lands on **the same `redirectUrl`** (never on the API host), carrying `oauth_error` + `error_description`:
|
|
243
|
+
```ts
|
|
244
|
+
const oauthError = params.get('oauth_error') as OAuthErrorCode | null;
|
|
245
|
+
if (oauthError) {
|
|
246
|
+
// `oauth_error` is a stable snake_case code — switch on it for localized copy.
|
|
247
|
+
// `error_description` is English developer detail; do not show it to shoppers.
|
|
248
|
+
if (oauthError === 'link_blocked_unverified_password_account') {
|
|
249
|
+
router.push('/verify-email'); // a retry will not help — the address needs verifying
|
|
250
|
+
} else {
|
|
251
|
+
router.push(`/login?error=${oauthError}`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
```
|
|
255
|
+
The code list is open — the provider's own codes (`access_denied`, …) pass through, so always handle the default case.
|
|
238
256
|
|
|
239
257
|
> Build the OAuth button region AND the callback handler even when no providers are configured.
|
|
240
258
|
|
|
@@ -701,6 +719,36 @@ const address: SetShippingAddressDto = {
|
|
|
701
719
|
};
|
|
702
720
|
```
|
|
703
721
|
|
|
722
|
+
**And no coordinates.** The address endpoints validate against a strict
|
|
723
|
+
allow-list — one property that isn't on the DTO rejects the whole call with
|
|
724
|
+
`400 "property lat should not exist"`, which doesn't degrade: it blocks the
|
|
725
|
+
address step for every shopper. `getAddressDetails()` resolves an address
|
|
726
|
+
carrying `lat`, `lng` and `formattedAddress`, so this is the one that bites:
|
|
727
|
+
|
|
728
|
+
```typescript
|
|
729
|
+
// ❌ WRONG — over raw HTTP, this 400s on every address
|
|
730
|
+
await fetch(`${base}/checkout/${id}/shipping-address`, {
|
|
731
|
+
method: 'PATCH',
|
|
732
|
+
body: JSON.stringify({ ...address, email, firstName, lastName }), // lat/lng ride along
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
// ✅ CORRECT — the SDK (>= 1.53.0) strips lat/lng/formattedAddress for you,
|
|
736
|
+
// so the spread is safe here. Pass `placeId`: that is what reaches zone
|
|
737
|
+
// matching, since the server resolves the coordinates itself and never takes
|
|
738
|
+
// them from the client (zone matching decides which rate gets charged).
|
|
739
|
+
await client.setShippingAddress(checkoutId, {
|
|
740
|
+
...address,
|
|
741
|
+
email,
|
|
742
|
+
firstName,
|
|
743
|
+
lastName,
|
|
744
|
+
placeId: picked.placeId,
|
|
745
|
+
placeSessionToken: sessionToken,
|
|
746
|
+
});
|
|
747
|
+
```
|
|
748
|
+
|
|
749
|
+
Use `address.lat` / `address.lng` for your own UI — a map pin, a distance
|
|
750
|
+
readout — and nothing else.
|
|
751
|
+
|
|
704
752
|
### 10. OAuth - Use `authorizationUrl`, NOT `url`
|
|
705
753
|
|
|
706
754
|
```typescript
|
|
@@ -967,9 +1015,23 @@ const suggestions = await client.getAddressSuggestions('Rothschild 1', sessionTo
|
|
|
967
1015
|
// [{ placeId: 'ChIJ...', description: 'Rothschild Blvd 1, Tel Aviv-Yafo' }, ...]
|
|
968
1016
|
|
|
969
1017
|
// On picking a suggestion — reuse the SAME sessionToken to end the session
|
|
970
|
-
const
|
|
1018
|
+
const picked = suggestions[0];
|
|
1019
|
+
const { address, inZone } = await client.getAddressDetails(picked.placeId, sessionToken);
|
|
971
1020
|
// address: { line1, city, region, postalCode, country, lat, lng, formattedAddress }
|
|
972
1021
|
|
|
1022
|
+
// `country` can come back as an EMPTY STRING, and so can `region`. Google
|
|
1023
|
+
// omits the country entirely for places whose sovereignty it declines to
|
|
1024
|
+
// attribute — verified live for ordinary residential addresses such as Ramat
|
|
1025
|
+
// Shlomo, Giv'at Ze'ev, Modi'in Ilit and Ma'ale Adumim. The API deliberately
|
|
1026
|
+
// does not guess one. Ask the shopper to confirm the country in that case; the
|
|
1027
|
+
// rest of the address (line1, city, lat/lng, formattedAddress) is still good.
|
|
1028
|
+
// Note this also makes `inZone` read false when the store's zones are
|
|
1029
|
+
// country-listed — a polygon zone can still match, since the server matches
|
|
1030
|
+
// against the coordinates it resolves from `placeId`.
|
|
1031
|
+
if (!address.country) {
|
|
1032
|
+
// leave your country <select> for the shopper rather than sending ''
|
|
1033
|
+
}
|
|
1034
|
+
|
|
973
1035
|
// address.region is Google's own administrative-area code — usually (not
|
|
974
1036
|
// guaranteed) the same ISO 3166-2 subdivision code this store's own region
|
|
975
1037
|
// lists use. Validate against destinations.regions before trusting it;
|
|
@@ -978,11 +1040,26 @@ const destinations = await client.getShippingDestinations();
|
|
|
978
1040
|
const validRegions = destinations.regions[address.country] ?? [];
|
|
979
1041
|
const region = validRegions.some((r) => r.code === address.region) ? address.region : '';
|
|
980
1042
|
|
|
1043
|
+
// Pass `placeId` through — that, not the coordinates, is what reaches zone
|
|
1044
|
+
// matching. The server never accepts `lat`/`lng` from the client, because zone
|
|
1045
|
+
// matching decides which shipping rate gets charged; it re-resolves the placeId
|
|
1046
|
+
// itself (cached, so no extra billed call) and matches map-drawn zones against
|
|
1047
|
+
// those exact coordinates instead of re-geocoding the text. Clear it if the
|
|
1048
|
+
// shopper then edits any address field.
|
|
1049
|
+
//
|
|
1050
|
+
// The `...address` spread below is safe even though `address` carries `lat`,
|
|
1051
|
+
// `lng` and `formattedAddress`: the SDK drops those three before sending. The
|
|
1052
|
+
// endpoint rejects unknown properties outright (`400 "property lat should not
|
|
1053
|
+
// exist"`), so if you call it over raw HTTP instead of the SDK, omit them
|
|
1054
|
+
// yourself. Use `address.lat`/`address.lng` for your own UI — a map pin, a
|
|
1055
|
+
// distance readout — and nothing else.
|
|
981
1056
|
await client.setShippingAddress(checkout.id, {
|
|
982
1057
|
firstName: 'John',
|
|
983
1058
|
lastName: 'Doe',
|
|
984
1059
|
...address,
|
|
985
1060
|
region,
|
|
1061
|
+
placeId: picked.placeId,
|
|
1062
|
+
placeSessionToken: sessionToken,
|
|
986
1063
|
});
|
|
987
1064
|
|
|
988
1065
|
if (!inZone) {
|
|
@@ -1722,7 +1799,10 @@ fields.forEach((field) => {
|
|
|
1722
1799
|
// field.minValue, field.maxValue: validation for number fields
|
|
1723
1800
|
// field.dateAvailability: constraints for DATE/DATETIME fields (blocked
|
|
1724
1801
|
// weekdays/dates, min/max date, business hours + slots) — see
|
|
1725
|
-
// computeAvailableSlots()/isDateValueAllowed()
|
|
1802
|
+
// computeAvailableSlots()/getBusinessHoursForDate()/isDateValueAllowed()
|
|
1803
|
+
// below. DATE values are sent as "YYYY-MM-DD"; DATETIME as one ISO-8601
|
|
1804
|
+
// value — never a date with a slot LABEL glued on ("...T13:00-14:00" is
|
|
1805
|
+
// read as a -14:00 UTC offset and rejected)
|
|
1726
1806
|
// field.enumValues: CustomizationFieldOption[] for SELECT/MULTI_SELECT
|
|
1727
1807
|
// each option: { label: string, value: string, swatchColor?: string, swatchImageUrl?: string }
|
|
1728
1808
|
// use option.value for metadata submission, option.label for display
|
|
@@ -2144,6 +2224,13 @@ These low-level methods are available for advanced use cases. For most storefron
|
|
|
2144
2224
|
const cart = await client.createCart();
|
|
2145
2225
|
```
|
|
2146
2226
|
|
|
2227
|
+
> **GA4 server-side conversions:** if you called `client.loadGoogleAnalytics('G-XXXXXXX')`
|
|
2228
|
+
> once at app startup (see [Analytics](#analytics-optional-server-side-ga4-conversions)
|
|
2229
|
+
> below), the resolved `client_id`/`session_id` are auto-attached to `createCart`,
|
|
2230
|
+
> `addToCart`, `setCheckoutCustomer`, and `setShippingAddress` automatically — no
|
|
2231
|
+
> other code changes needed. Pass `analyticsClientId`/`analyticsSessionId`
|
|
2232
|
+
> explicitly on any of these calls to override.
|
|
2233
|
+
|
|
2147
2234
|
#### Get Cart
|
|
2148
2235
|
|
|
2149
2236
|
```typescript
|
|
@@ -2164,6 +2251,8 @@ const cart = await client.addToCart(cartId, {
|
|
|
2164
2251
|
variantId: 'variant_id', // Optional: for VARIABLE products
|
|
2165
2252
|
quantity: 2,
|
|
2166
2253
|
notes: 'Gift wrap please', // Optional
|
|
2254
|
+
// analyticsClientId / analyticsSessionId: auto-attached if you called
|
|
2255
|
+
// loadGoogleAnalytics() — no need to pass these yourself.
|
|
2167
2256
|
});
|
|
2168
2257
|
```
|
|
2169
2258
|
|
|
@@ -2214,6 +2303,9 @@ interface Cart {
|
|
|
2214
2303
|
couponCode?: string | null;
|
|
2215
2304
|
items: CartItem[];
|
|
2216
2305
|
itemCount: number;
|
|
2306
|
+
// GA4 stitch ids, if forwarded — see loadGoogleAnalytics() under Analytics.
|
|
2307
|
+
analyticsClientId?: string | null;
|
|
2308
|
+
analyticsSessionId?: string | null;
|
|
2217
2309
|
createdAt: string;
|
|
2218
2310
|
updatedAt: string;
|
|
2219
2311
|
}
|
|
@@ -2340,6 +2432,8 @@ const checkout = await client.setCheckoutCustomer(checkoutId, {
|
|
|
2340
2432
|
lastName: 'Doe',
|
|
2341
2433
|
phone: '+1234567890', // Optional
|
|
2342
2434
|
notes: 'Please leave the package at the door', // Optional order note (max 2000 chars)
|
|
2435
|
+
// analyticsClientId / analyticsSessionId: auto-attached if you called
|
|
2436
|
+
// loadGoogleAnalytics() — no need to pass these yourself.
|
|
2343
2437
|
});
|
|
2344
2438
|
```
|
|
2345
2439
|
|
|
@@ -2357,12 +2451,44 @@ const { checkout, rates } = await client.setShippingAddress(checkoutId, {
|
|
|
2357
2451
|
country: 'US',
|
|
2358
2452
|
phone: '+1234567890', // Optional
|
|
2359
2453
|
notes: 'Please leave the package at the door', // Optional order note (max 2000 chars)
|
|
2454
|
+
placeId: picked.placeId, // Send whenever the address came from the autocomplete
|
|
2455
|
+
placeSessionToken: sessionToken, // Optional — the same token used for it
|
|
2360
2456
|
});
|
|
2361
2457
|
|
|
2362
2458
|
// rates contains available shipping options
|
|
2363
2459
|
console.log(rates); // ShippingRate[]
|
|
2364
2460
|
```
|
|
2365
2461
|
|
|
2462
|
+
> **Always pass `placeId` when you use address autocomplete.** The server
|
|
2463
|
+
> re-resolves it to the address's exact coordinates, which is how stores that
|
|
2464
|
+
> draw their delivery areas on a map ("polygon" zones) decide whether they
|
|
2465
|
+
> cover the shopper. Without it the server falls back to geocoding the typed
|
|
2466
|
+
> address text, which is materially less precise — a same-named street in a
|
|
2467
|
+
> neighbouring city can outrank the right one, quoting the shopper another
|
|
2468
|
+
> area's rate or no delivery at all. **Clear `placeId` if the shopper edits any
|
|
2469
|
+
> address field after picking** — its coordinates describe the suggestion, not
|
|
2470
|
+
> the edited text. There is deliberately no `lat`/`lng` field: zone matching
|
|
2471
|
+
> decides which rate is charged, so coordinates are never accepted from the
|
|
2472
|
+
> client — the server resolves them from `placeId` itself.
|
|
2473
|
+
>
|
|
2474
|
+
> The endpoint rejects **any** unknown property with a `400` ("property lat
|
|
2475
|
+
> should not exist"), which blocks checkout entirely rather than degrading. So
|
|
2476
|
+
> that spreading `getAddressDetails().address` in here can't do that, the SDK
|
|
2477
|
+
> drops its `lat`, `lng` and `formattedAddress` before sending (once per client
|
|
2478
|
+
> it logs a `console.warn` saying so). Calling the REST endpoint directly
|
|
2479
|
+
> instead of through the SDK? Then omit those three yourself.
|
|
2480
|
+
|
|
2481
|
+
> **One zone per address:** an address matches exactly ONE shipping zone and you
|
|
2482
|
+
> get only that zone's rates. When zones overlap, the lower `priority` wins;
|
|
2483
|
+
> otherwise the smaller drawn area does. If you want several options for the
|
|
2484
|
+
> same area, put several rates on one zone rather than several zones.
|
|
2485
|
+
|
|
2486
|
+
> **Live carrier rates:** render `rate.speedTier` (`'cheapest' | 'balanced' | 'fastest'`)
|
|
2487
|
+
> and `rate.estimatedDays`, not `rate.name` — `name` is the carrier's own service
|
|
2488
|
+
> identifier and means nothing to a shopper. Rates arrive already narrowed to at most
|
|
2489
|
+
> three. Manual zone rates carry no `speedTier`; show their `name` as the merchant wrote
|
|
2490
|
+
> it. Full snippet under [Checkout Type Definition](#checkout-type-definition).
|
|
2491
|
+
|
|
2366
2492
|
> **Order notes:** every checkout page should render an optional **"Order
|
|
2367
2493
|
> notes"** textarea by default. Send its value via `notes` on either
|
|
2368
2494
|
> `setCheckoutCustomer` or `setShippingAddress` (whichever call your flow
|
|
@@ -2427,25 +2553,58 @@ const updatedCheckout = await client.setCheckoutCustomFields(checkoutId, {
|
|
|
2427
2553
|
A `DATE`/`DATETIME` field's `dateAvailability` (blocked weekdays, blocked specific
|
|
2428
2554
|
dates, min/max date range, and — for `DATETIME` — business hours + time
|
|
2429
2555
|
slots) is a merchant-configured restriction on which values the customer may
|
|
2430
|
-
pick. Use `computeAvailableSlots()` / `
|
|
2431
|
-
date-picker/slot-picker UI — the SDK
|
|
2432
|
-
math (evaluated in the **store's**
|
|
2556
|
+
pick. Use `computeAvailableSlots()` / `getBusinessHoursForDate()` /
|
|
2557
|
+
`isDateValueAllowed()` to drive your own date-picker/slot-picker UI — the SDK
|
|
2558
|
+
ships no calendar component, only the math (evaluated in the **store's**
|
|
2559
|
+
timezone, never the browser's):
|
|
2433
2560
|
|
|
2434
2561
|
```typescript
|
|
2435
|
-
import {
|
|
2562
|
+
import {
|
|
2563
|
+
computeAvailableSlots,
|
|
2564
|
+
getBusinessHoursForDate,
|
|
2565
|
+
isCalendarDateAllowed,
|
|
2566
|
+
isDateValueAllowed,
|
|
2567
|
+
} from 'brainerce';
|
|
2436
2568
|
|
|
2437
2569
|
const { timezone } = await client.getStoreInfo(); // IANA string, e.g. "Asia/Jerusalem"
|
|
2438
2570
|
const deliveryField = fields.find((f) => f.key === 'delivery_slot');
|
|
2571
|
+
const availability = deliveryField?.dateAvailability;
|
|
2572
|
+
|
|
2573
|
+
// Disable days on your calendar of choice. Note the SECOND condition: once
|
|
2574
|
+
// businessHours has any entry it is an ALLOWLIST, so a weekday it doesn't
|
|
2575
|
+
// mention is closed all day even though the calendar rules accept it.
|
|
2576
|
+
const isDayDisabled = (ymd: string) =>
|
|
2577
|
+
!isCalendarDateAllowed(ymd, availability) ||
|
|
2578
|
+
(!!availability?.businessHours?.length &&
|
|
2579
|
+
getBusinessHoursForDate(availability, ymd).length === 0);
|
|
2580
|
+
|
|
2581
|
+
// Once the customer picks a day, offer times. computeAvailableSlots() returns
|
|
2582
|
+
// [] when the field has no slotDurationMinutes — that is NOT "day closed",
|
|
2583
|
+
// which is why the windows are checked separately.
|
|
2584
|
+
const slots = computeAvailableSlots(availability, '2026-08-15'); // ["09:00", "09:30", ...]
|
|
2585
|
+
const windows = getBusinessHoursForDate(availability, '2026-08-15'); // [{ weekday, open, close }]
|
|
2586
|
+
|
|
2587
|
+
if (slots.length) {
|
|
2588
|
+
// Render slot buttons; the submitted time must equal a slot start exactly.
|
|
2589
|
+
} else if (windows.length) {
|
|
2590
|
+
// Render a free time input bounded by [windows[0].open, windows[0].close).
|
|
2591
|
+
} else {
|
|
2592
|
+
// Genuinely closed that day.
|
|
2593
|
+
}
|
|
2594
|
+
```
|
|
2439
2595
|
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2596
|
+
**Submitting the value.** `DATE` is `"YYYY-MM-DD"`. `DATETIME` is one ISO-8601
|
|
2597
|
+
value — `"2026-08-15T09:30:00+03:00"`, or `"2026-08-15T09:30"` to mean the
|
|
2598
|
+
store's own timezone (the safest choice: a buyer travelling abroad would
|
|
2599
|
+
otherwise book their local hour). Fractional seconds are optional and may carry
|
|
2600
|
+
1–9 digits, so `Instant.toString()` / `datetime.isoformat()` output from a
|
|
2601
|
+
non-JS backend is accepted as-is. **Never build it by concatenating a slot
|
|
2602
|
+
label**: `` `${date}T13:00-14:00` `` is rejected with HTTP 400 because
|
|
2603
|
+
`-14:00` parses as a UTC offset, not a time range.
|
|
2445
2604
|
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2605
|
+
What you read back is normalized, not the string you sent: `YYYY-MM-DD` for
|
|
2606
|
+
`DATE`, an ISO-8601 UTC instant for `DATETIME`. Use `parseDateFieldValue()` if
|
|
2607
|
+
you want to apply the exact same parse client-side before submitting.
|
|
2449
2608
|
|
|
2450
2609
|
The backend independently re-validates every submitted value against the same
|
|
2451
2610
|
constraints at write time — this is a client-side UX aid, not the source of
|
|
@@ -2492,9 +2651,36 @@ interface ShippingRate {
|
|
|
2492
2651
|
price: string;
|
|
2493
2652
|
currency: string;
|
|
2494
2653
|
estimatedDays?: number | null;
|
|
2654
|
+
source?: 'manual' | 'carrier'; // 'manual' = merchant's zone rate, 'carrier' = live quote
|
|
2655
|
+
carrier?: string; // carrier rates only, e.g. 'USPS'
|
|
2656
|
+
service?: string; // carrier rates only, e.g. 'Priority'
|
|
2657
|
+
speedTier?: 'cheapest' | 'balanced' | 'fastest'; // carrier rates only — render this, not `name`
|
|
2495
2658
|
}
|
|
2496
2659
|
```
|
|
2497
2660
|
|
|
2661
|
+
**Render `speedTier` and `estimatedDays`, not `name`, for live carrier rates.**
|
|
2662
|
+
`name` carries the carrier's own service identifier — `USPS PriorityMailInternational`,
|
|
2663
|
+
`USAExportPBA USAExportStandard` — which answers a question no shopper asked. They are
|
|
2664
|
+
choosing between _how fast_ and _how much_. Label the tiers in your own words and locale:
|
|
2665
|
+
|
|
2666
|
+
```typescript
|
|
2667
|
+
const LABELS = {
|
|
2668
|
+
cheapest: 'Standard delivery',
|
|
2669
|
+
balanced: 'Express delivery',
|
|
2670
|
+
fastest: 'Priority delivery',
|
|
2671
|
+
};
|
|
2672
|
+
|
|
2673
|
+
// Manual zone rates carry no speedTier — the merchant named those
|
|
2674
|
+
// deliberately, so show their name exactly as written.
|
|
2675
|
+
const label = rate.speedTier ? LABELS[rate.speedTier] : rate.name;
|
|
2676
|
+
```
|
|
2677
|
+
|
|
2678
|
+
Live carrier rates arrive already narrowed to at most three: the cheapest, the fastest,
|
|
2679
|
+
and one genuinely in between when it earns its place (cheaper than the fastest _and_
|
|
2680
|
+
quicker than the cheapest). You will not receive seven near-identical services to filter
|
|
2681
|
+
yourself. The tiers are derived from each quote rather than mapped from service names, so
|
|
2682
|
+
a carrier you have never heard of tiers correctly with no lookup table to maintain.
|
|
2683
|
+
|
|
2498
2684
|
#### Shipping Rates: Complete Flow
|
|
2499
2685
|
|
|
2500
2686
|
The shipping flow involves setting an address and then selecting from available rates:
|
|
@@ -2530,7 +2716,14 @@ if (rates.length === 0) {
|
|
|
2530
2716
|
);
|
|
2531
2717
|
}
|
|
2532
2718
|
|
|
2533
|
-
// Step 3: Display available rates to customer
|
|
2719
|
+
// Step 3: Display available rates to customer.
|
|
2720
|
+
// Your own words for the carrier tiers — see "Checkout Type Definition" above.
|
|
2721
|
+
const LABELS = {
|
|
2722
|
+
cheapest: 'Standard delivery',
|
|
2723
|
+
balanced: 'Express delivery',
|
|
2724
|
+
fastest: 'Priority delivery',
|
|
2725
|
+
};
|
|
2726
|
+
|
|
2534
2727
|
<div className="space-y-2">
|
|
2535
2728
|
<h3 className="font-medium">Select Shipping Method</h3>
|
|
2536
2729
|
{rates.map((rate) => (
|
|
@@ -2543,7 +2736,8 @@ if (rates.length === 0) {
|
|
|
2543
2736
|
onChange={() => setSelectedRateId(rate.id)}
|
|
2544
2737
|
/>
|
|
2545
2738
|
<div className="flex-1">
|
|
2546
|
-
|
|
2739
|
+
{/* speedTier for live carrier rates; the merchant's own name for zone rates */}
|
|
2740
|
+
<span className="font-medium">{rate.speedTier ? LABELS[rate.speedTier] : rate.name}</span>
|
|
2547
2741
|
{rate.description && <p className="text-sm text-gray-500">{rate.description}</p>}
|
|
2548
2742
|
{rate.estimatedDays && (
|
|
2549
2743
|
<p className="text-sm text-gray-500">Estimated delivery: {rate.estimatedDays} business days</p>
|
|
@@ -3490,14 +3684,17 @@ window.location.href = authorizationUrl;
|
|
|
3490
3684
|
|
|
3491
3685
|
**Step 2: Create callback page (`/auth/callback`)**
|
|
3492
3686
|
|
|
3493
|
-
The backend handles the OAuth code exchange automatically and redirects to your callback page with URL params. You do **not** need to call `handleOAuthCallback()
|
|
3687
|
+
The backend handles the OAuth code exchange automatically and redirects to your callback page with URL params. You do **not** need to call `handleOAuthCallback()`.
|
|
3688
|
+
|
|
3689
|
+
Both outcomes land here — success **and** failure. Read `auth_code` and exchange it for the JWT; never read the token from the URL.
|
|
3494
3690
|
|
|
3495
3691
|
```typescript
|
|
3496
3692
|
// app/auth/callback/page.tsx
|
|
3497
3693
|
'use client';
|
|
3498
3694
|
import { useEffect, useState } from 'react';
|
|
3499
3695
|
import { useSearchParams } from 'next/navigation';
|
|
3500
|
-
import {
|
|
3696
|
+
import type { OAuthErrorCode } from 'brainerce';
|
|
3697
|
+
import { client, setCustomerToken } from '@/lib/brainerce';
|
|
3501
3698
|
|
|
3502
3699
|
export default function AuthCallback() {
|
|
3503
3700
|
const searchParams = useSearchParams();
|
|
@@ -3505,25 +3702,35 @@ export default function AuthCallback() {
|
|
|
3505
3702
|
|
|
3506
3703
|
useEffect(() => {
|
|
3507
3704
|
const oauthSuccess = searchParams.get('oauth_success');
|
|
3508
|
-
const
|
|
3509
|
-
const oauthError = searchParams.get('oauth_error');
|
|
3705
|
+
const authCode = searchParams.get('auth_code');
|
|
3706
|
+
const oauthError = searchParams.get('oauth_error') as OAuthErrorCode | null;
|
|
3510
3707
|
|
|
3511
|
-
//
|
|
3708
|
+
// Failure path: `oauth_error` is a stable snake_case code — switch on it.
|
|
3709
|
+
// `error_description` is English developer detail, NOT shopper-facing copy.
|
|
3512
3710
|
if (oauthError) {
|
|
3513
|
-
setError(
|
|
3711
|
+
setError(
|
|
3712
|
+
oauthError === 'link_blocked_unverified_password_account'
|
|
3713
|
+
? 'Please verify your email address first, then link your social account.'
|
|
3714
|
+
: oauthError === 'state_expired'
|
|
3715
|
+
? 'That sign-in link expired. Please try again.'
|
|
3716
|
+
: 'Sign-in could not be completed. Please try again.'
|
|
3717
|
+
);
|
|
3514
3718
|
return;
|
|
3515
3719
|
}
|
|
3516
3720
|
|
|
3517
|
-
if (oauthSuccess === 'true' &&
|
|
3518
|
-
//
|
|
3519
|
-
|
|
3520
|
-
|
|
3521
|
-
|
|
3522
|
-
|
|
3523
|
-
|
|
3524
|
-
|
|
3525
|
-
|
|
3526
|
-
|
|
3721
|
+
if (oauthSuccess === 'true' && authCode) {
|
|
3722
|
+
// Swap the single-use code for the JWT. It is valid for 2 minutes and
|
|
3723
|
+
// dies on first use, which is why the token never rides in the URL.
|
|
3724
|
+
client
|
|
3725
|
+
.exchangeOAuthCode(authCode)
|
|
3726
|
+
.then((result) => {
|
|
3727
|
+
setCustomerToken(result.token);
|
|
3728
|
+
// Also on `result`: customer, isNewCustomer, linkedToExisting, redirectUrl
|
|
3729
|
+
const returnUrl = localStorage.getItem('returnUrl') || '/';
|
|
3730
|
+
localStorage.removeItem('returnUrl');
|
|
3731
|
+
window.location.href = returnUrl;
|
|
3732
|
+
})
|
|
3733
|
+
.catch(() => setError('Sign-in could not be completed. Please try again.'));
|
|
3527
3734
|
} else {
|
|
3528
3735
|
setError('Missing authentication parameters');
|
|
3529
3736
|
}
|
|
@@ -3848,6 +4055,35 @@ client.trackEvent({ eventType: 'engagement', path: '/products/shoes', engagedMs:
|
|
|
3848
4055
|
|
|
3849
4056
|
---
|
|
3850
4057
|
|
|
4058
|
+
### Analytics (optional, server-side GA4 conversions)
|
|
4059
|
+
|
|
4060
|
+
If the store has the **Google & YouTube** app installed with a GA4 property connected, Brainerce can send server-side `purchase` conversions via the GA4 Measurement Protocol — recovering the 15-40% of conversions client-side `gtag.js` typically loses to ad-blockers, ITP/Safari cookie capping, and the post-payment redirect dropping the page before the browser beacon fires.
|
|
4061
|
+
|
|
4062
|
+
For the server-sent purchase to join the shopper's on-page GA4 session (instead of creating a duplicate/orphan user), it needs the same `client_id`/`session_id` gtag.js is using in the browser. Call `loadGoogleAnalytics()` once and the SDK handles the rest:
|
|
4063
|
+
|
|
4064
|
+
```typescript
|
|
4065
|
+
// Call once, as early as possible (app entry / root layout)
|
|
4066
|
+
client.loadGoogleAnalytics('G-XXXXXXX'); // your GA4 Measurement ID
|
|
4067
|
+
|
|
4068
|
+
// Every cart/checkout call from here on auto-forwards the resolved
|
|
4069
|
+
// client_id/session_id — no other code changes needed.
|
|
4070
|
+
const cart = await client.createCart();
|
|
4071
|
+
await client.addToCart(cart.id, { productId: 'prod_abc', quantity: 1 });
|
|
4072
|
+
```
|
|
4073
|
+
|
|
4074
|
+
What this does:
|
|
4075
|
+
|
|
4076
|
+
- Idempotently injects `gtag.js` and initializes `dataLayer` (skips injection if you're already loading `gtag.js` yourself — safe to call either way).
|
|
4077
|
+
- Resolves `client_id`/`session_id` via `gtag('get', measurementId, 'client_id' | 'session_id', cb)` — Google's documented method, not `_ga` cookie-parsing (which breaks across cookie-format changes and Consent Mode v2 states).
|
|
4078
|
+
- Auto-attaches the resolved ids to `createCart()`, `addToCart()`, `setCheckoutCustomer()`, and `setShippingAddress()`. An explicit `analyticsClientId`/`analyticsSessionId` you pass to any of those calls always wins over the auto-captured value.
|
|
4079
|
+
- Never throws and never delays a cart/checkout call by more than ~1.5s (configurable via `{ timeoutMs }`) — a blocked or slow `gtag.js`, or a shopper who denied analytics consent, just means the server-side conversion won't stitch. It never breaks checkout.
|
|
4080
|
+
|
|
4081
|
+
You still need to paste the GA4 **Measurement Protocol API secret** once in the dashboard (**Apps → Google & YouTube → Analytics**) — create it in GA4 Admin → Data Streams → your stream → Measurement Protocol API secrets. Without it (or without `loadGoogleAnalytics()` ever being called), the platform simply skips the server-side event — nothing breaks, GA4 just doesn't get the extra signal.
|
|
4082
|
+
|
|
4083
|
+
> This is entirely optional and separate from Brainerce's [Traffic Analytics](#traffic-analytics-built-in-no-ga4-needed) above, which needs no GA4 account at all.
|
|
4084
|
+
|
|
4085
|
+
---
|
|
4086
|
+
|
|
3851
4087
|
## Admin API Reference
|
|
3852
4088
|
|
|
3853
4089
|
> ⛔ **Server-side only.** The `apiKey` (`brainerce_*`) is a privileged secret — NEVER put it in browser code, client bundles, or any code that ships to the user's machine. It belongs in an environment variable on your server. For building the customer-facing storefront, use `salesChannelId` instead (see [Quick Start](#quick-start)).
|
|
@@ -4111,26 +4347,89 @@ await client.createZoneShippingRate('zone_id', {
|
|
|
4111
4347
|
});
|
|
4112
4348
|
```
|
|
4113
4349
|
|
|
4114
|
-
### App Store Shipping (
|
|
4350
|
+
### App Store Shipping (live carrier rates)
|
|
4115
4351
|
|
|
4116
|
-
Once a merchant installs
|
|
4117
|
-
|
|
4118
|
-
to the merchant's
|
|
4352
|
+
Once a merchant installs a shipping app from the Brainerce App Store — EasyPost, Shippo, or any
|
|
4353
|
+
future carrier — and connects their own carrier account, live rates appear automatically at
|
|
4354
|
+
checkout. Billing goes directly to the merchant's carrier account.
|
|
4355
|
+
|
|
4356
|
+
Every carrier app implements the same Brainerce shipping contract, so this code is identical
|
|
4357
|
+
regardless of which provider the merchant chose. Checkout rate ids are prefixed `carrier:` and
|
|
4358
|
+
must be passed back whole; label rate ids are opaque and must not be parsed.
|
|
4119
4359
|
|
|
4120
4360
|
```typescript
|
|
4121
|
-
// After an order is placed,
|
|
4361
|
+
// After an order is placed: quote, then buy.
|
|
4362
|
+
const rates = await admin.getOrderShippingRates(orderId);
|
|
4363
|
+
|
|
4122
4364
|
const label = await admin.createShippingLabel(orderId, {
|
|
4123
|
-
|
|
4365
|
+
rateId: rates[0].id, // opaque — pass it through, never parse it
|
|
4366
|
+
labelFormat: 'PDF', // 'PDF' | 'PNG' | 'ZPL' | 'EPL' — ZPL/EPL for thermal printers
|
|
4124
4367
|
});
|
|
4125
4368
|
|
|
4126
|
-
console.log(label.labelUrl); //
|
|
4369
|
+
console.log(label.labelUrl); // Label file URL for printing
|
|
4127
4370
|
console.log(label.trackingNumber); // Auto-populated on the order
|
|
4128
4371
|
console.log(label.carrier); // e.g. 'USPS', 'UPS', 'FedEx'
|
|
4372
|
+
console.log(label.labelFormat); // What the carrier actually produced
|
|
4129
4373
|
```
|
|
4130
4374
|
|
|
4131
4375
|
The `trackingNumber` is stored on the `Order` automatically — customers see it in their
|
|
4132
4376
|
order history without any extra integration work.
|
|
4133
4377
|
|
|
4378
|
+
**Buy the service the shopper paid for.** `order.shippingSelection` records the live carrier
|
|
4379
|
+
service that was sold at checkout — `{ carrier, service, methodName, amount }` — or `null` when
|
|
4380
|
+
the order sold a flat-rate/zone rate and there is nothing to match. Rate ids do not survive a
|
|
4381
|
+
re-quote, so re-find it on `carrier` + `service`, trimmed and lower-cased:
|
|
4382
|
+
|
|
4383
|
+
```typescript
|
|
4384
|
+
const order = await admin.getOrder(orderId);
|
|
4385
|
+
const paidFor = order.shippingSelection;
|
|
4386
|
+
const norm = (v?: string | null) => (v ?? '').trim().toLowerCase();
|
|
4387
|
+
|
|
4388
|
+
const preferred = paidFor
|
|
4389
|
+
? rates.find(
|
|
4390
|
+
(r) => norm(r.carrier) === norm(paidFor.carrier) && norm(r.service) === norm(paidFor.service)
|
|
4391
|
+
)
|
|
4392
|
+
: undefined;
|
|
4393
|
+
```
|
|
4394
|
+
|
|
4395
|
+
Buying a cheaper, slower service than the one the shopper was charged for is a silent
|
|
4396
|
+
downgrade of what they bought. When the paid-for service is not in the fresh quote, say so and
|
|
4397
|
+
let a human choose — do not substitute one automatically.
|
|
4398
|
+
|
|
4399
|
+
**Tracking updates are automatic.** Once the label exists, the carrier's tracking webhooks
|
|
4400
|
+
flow back through the shipping app and move the shipment through its lifecycle — in transit,
|
|
4401
|
+
out for delivery, delivered. On delivery the order is completed and the customer notification
|
|
4402
|
+
fires. You never poll for status — read the history when you want to show it:
|
|
4403
|
+
|
|
4404
|
+
```typescript
|
|
4405
|
+
const shipments = await admin.getOrderShipments(orderId);
|
|
4406
|
+
|
|
4407
|
+
for (const s of shipments) {
|
|
4408
|
+
console.log(s.carrier, s.trackingNumber, s.status);
|
|
4409
|
+
// Newest first, capped at the 200 most recent events per shipment
|
|
4410
|
+
for (const e of s.events) {
|
|
4411
|
+
console.log(e.occurredAt, e.message, e.location?.city, e.location?.country);
|
|
4412
|
+
}
|
|
4413
|
+
}
|
|
4414
|
+
```
|
|
4415
|
+
|
|
4416
|
+
**Quote immediately before you buy.** `getOrderShippingRates()` is what creates the shipment
|
|
4417
|
+
at the carrier — the rate id points at it — and carriers do not allow amending one afterwards.
|
|
4418
|
+
A rate held from an earlier call may no longer be purchasable.
|
|
4419
|
+
|
|
4420
|
+
### Cross-border shipments
|
|
4421
|
+
|
|
4422
|
+
Customs declarations are handled for you: the platform builds one from the order's line items
|
|
4423
|
+
whenever the destination country differs from the merchant's ship-from country. Pass
|
|
4424
|
+
`customsContentsType` if the parcel is a gift, sample, document or return rather than
|
|
4425
|
+
merchandise.
|
|
4426
|
+
|
|
4427
|
+
One case needs the merchant: a US-origin export where any single commodity line exceeds
|
|
4428
|
+
**$2,500** cannot use the ordinary EEI exemption. The exporter must file with AES and supply
|
|
4429
|
+
the resulting ITN. Brainerce deliberately does **not** assert the exemption on those shipments —
|
|
4430
|
+
it is a declaration to US Customs, not a formality — so the carrier will refuse the label until
|
|
4431
|
+
a real citation is provided.
|
|
4432
|
+
|
|
4134
4433
|
### Tax Configuration
|
|
4135
4434
|
|
|
4136
4435
|
`rate` is a **whole percentage** (`7.25` = 7.25%, not `0.0725`). A rate may target
|
|
@@ -4984,6 +5283,9 @@ import { useState, useEffect } from 'react';
|
|
|
4984
5283
|
import { client, isLoggedIn, restoreCustomerToken } from '@/lib/brainerce';
|
|
4985
5284
|
import type { Checkout, ShippingRate } from 'brainerce';
|
|
4986
5285
|
|
|
5286
|
+
// Live carrier rates carry a speedTier instead of a shopper-readable name.
|
|
5287
|
+
const TIER_LABELS = { cheapest: 'Standard delivery', balanced: 'Express delivery', fastest: 'Priority delivery' };
|
|
5288
|
+
|
|
4987
5289
|
export default function CheckoutPage() {
|
|
4988
5290
|
const [loading, setLoading] = useState(true);
|
|
4989
5291
|
const [submitting, setSubmitting] = useState(false);
|
|
@@ -5078,7 +5380,8 @@ export default function CheckoutPage() {
|
|
|
5078
5380
|
{shippingRates.length > 0 && (
|
|
5079
5381
|
<select value={selectedRate || ''} onChange={(e) => setSelectedRate(e.target.value)}>
|
|
5080
5382
|
{shippingRates.map((rate) => (
|
|
5081
|
-
|
|
5383
|
+
// speedTier for live carrier rates; the merchant's own name for zone rates
|
|
5384
|
+
<option key={rate.id} value={rate.id}>{rate.speedTier ? TIER_LABELS[rate.speedTier] : rate.name} - ${rate.price}</option>
|
|
5082
5385
|
))}
|
|
5083
5386
|
</select>
|
|
5084
5387
|
)}
|