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 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 and apply via `setCustomerToken`. (The legacy `?token=` URL param is still emitted for backward compatibility but will be removed in the next major release.)
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); // then redirect to account
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 `oauth_error` query param: redirect to login with an error message.
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 { address, inZone } = await client.getAddressDetails(suggestions[0].placeId, sessionToken);
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() below
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()` / `isDateValueAllowed()` to drive your own
2431
- date-picker/slot-picker UI — the SDK ships no calendar component, only the
2432
- math (evaluated in the **store's** timezone, never the browser'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 { computeAvailableSlots, isDateValueAllowed } from 'brainerce';
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
- // Disable dates on your calendar of choice:
2441
- const isDateDisabled = (candidate: Date) => {
2442
- const result = isDateValueAllowed(candidate, deliveryField?.dateAvailability, deliveryField!.type, timezone);
2443
- return !result.allowed;
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
- // Once the customer picks a date, list its bookable time slots (DATETIME only):
2447
- const slots = computeAvailableSlots(deliveryField?.dateAvailability, '2026-08-15'); // ["09:00", "09:30", ...]
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
- <span className="font-medium">{rate.name}</span>
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()` — just read the token from the URL.
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 { setCustomerToken } from '@/lib/brainerce';
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 token = searchParams.get('token');
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
- // Check for OAuth errors (user cancelled, provider error, etc.)
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(oauthError);
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' && token) {
3518
- // Save the customer token
3519
- setCustomerToken(token);
3520
-
3521
- // Also available in URL params: customer_id, customer_email, is_new
3522
-
3523
- // Redirect to return URL or home
3524
- const returnUrl = localStorage.getItem('returnUrl') || '/';
3525
- localStorage.removeItem('returnUrl');
3526
- window.location.href = returnUrl;
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 (Shippo)
4350
+ ### App Store Shipping (live carrier rates)
4115
4351
 
4116
- Once a merchant installs the Shippo app from the Brainerce App Store and connects their own
4117
- Shippo account, live carrier rates appear automatically at checkout. Billing goes directly
4118
- to the merchant's Shippo account.
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, purchase a shipping label:
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
- shippoRateObjectId: 'rate_8f123456789abcdef', // Shippo rate.object_id from checkout rates
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); // PDF label URL for printing
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
- <option key={rate.id} value={rate.id}>{rate.name} - ${rate.price}</option>
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
  )}