brainerce 1.63.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 CHANGED
@@ -55,7 +55,7 @@ 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()` | ✅ |
@@ -66,7 +66,7 @@ Every Brainerce storefront must include **all mandatory features** below. Featur
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.getCart()`, `client.getSearchSuggestions(query)` | ✅ |
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
72
  | Customer photos on reviews | `client.uploadReviewPhoto(productId, file)`, then `imageKeys` on submit | conditional |
@@ -172,7 +172,7 @@ These sequences are non-negotiable. The order of SDK calls matters.
172
172
  5. Confirm payment using the provider's flow (Stripe Elements `stripe.confirmCardPayment`, PayPal button, redirect, etc.).
173
173
  6. On the confirmation page, **always call both**:
174
174
  ```ts
175
- await client.handlePaymentSuccess(checkoutId); // clears cart
175
+ client.handlePaymentSuccess(checkoutId); // synchronous, clears cart. Do NOT await it.
176
176
  const order = await client.waitForOrder(checkoutId); // polls until order exists
177
177
  ```
178
178
  7. Display `checkout.lineItems` (not `cart.items`) on the order summary.
@@ -210,7 +210,22 @@ These sequences are non-negotiable. The order of SDK calls matters.
210
210
  ### Order confirmation flow
211
211
 
212
212
  1. Read `checkoutId` from URL or session.
213
- 2. `await client.handlePaymentSuccess(checkoutId)` mandatory, clears cart so purchased items don't show on next visit.
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`.
214
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.
215
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.
216
231
  5. On success: render the order number, or — if your design wants more than that — fetch full details:
@@ -280,7 +295,7 @@ the credential, no customer token needed.
280
295
  ### Inventory reservation flow
281
296
 
282
297
  - Display the countdown from `cart.reservation?.expiresAt` — refresh once per second (`reservation` is optional; only present when a reservation strategy is active).
283
- - 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.
284
299
  - On the checkout page: if reservation has expired, block payment and show "your cart has expired" with a link back to cart.
285
300
  - Do NOT implement your own timer logic — the SDK is the source of truth.
286
301
 
@@ -1026,8 +1041,9 @@ await client.setCheckoutCustomer(checkout.id, {
1026
1041
  lastName: 'Doe',
1027
1042
  });
1028
1043
 
1029
- // 5. Set shipping address
1044
+ // 5. Set shipping address (email is required here too, even though step 4 sent it)
1030
1045
  await client.setShippingAddress(checkout.id, {
1046
+ email: 'customer@example.com',
1031
1047
  firstName: 'John',
1032
1048
  lastName: 'Doe',
1033
1049
  line1: '123 Main St',
@@ -1116,6 +1132,7 @@ const region = validRegions.some((r) => r.code === address.region) ? address.reg
1116
1132
  // yourself. Use `address.lat`/`address.lng` for your own UI — a map pin, a
1117
1133
  // distance readout — and nothing else.
1118
1134
  await client.setShippingAddress(checkout.id, {
1135
+ email: 'customer@example.com', // required. The resolved address carries no email.
1119
1136
  firstName: 'John',
1120
1137
  lastName: 'Doe',
1121
1138
  ...address,
@@ -2109,6 +2126,160 @@ function ProductDescription({ product }: { product: Product }) {
2109
2126
 
2110
2127
  ---
2111
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
+
2112
2283
  ### Cart Operations (All Users)
2113
2284
 
2114
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.
@@ -2120,9 +2291,35 @@ await client.smartAddToCart({
2120
2291
  productId: 'prod_123',
2121
2292
  variantId: 'var_456', // Optional: for products with variants
2122
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
+ },
2123
2313
  });
2124
2314
  ```
2125
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
+
2126
2323
  #### Get Cart
2127
2324
 
2128
2325
  ```typescript
@@ -2576,6 +2773,7 @@ const checkout = await client.setCheckoutCustomer(checkoutId, {
2576
2773
 
2577
2774
  ```typescript
2578
2775
  const { checkout, rates } = await client.setShippingAddress(checkoutId, {
2776
+ email: 'customer@example.com', // REQUIRED, on every call
2579
2777
  firstName: 'John',
2580
2778
  lastName: 'Doe',
2581
2779
  line1: '123 Main St',
@@ -2594,6 +2792,11 @@ const { checkout, rates } = await client.setShippingAddress(checkoutId, {
2594
2792
  console.log(rates); // ShippingRate[]
2595
2793
  ```
2596
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
+
2597
2800
  > **Always pass `placeId` when you use address autocomplete.** The server
2598
2801
  > re-resolves it to the address's exact coordinates, which is how stores that
2599
2802
  > draw their delivery areas on a map ("polygon" zones) decide whether they
@@ -2842,6 +3045,7 @@ The shipping flow involves setting an address and then selecting from available
2842
3045
  ```typescript
2843
3046
  // Step 1: Set shipping address - this returns available rates
2844
3047
  const { checkout, rates } = await client.setShippingAddress(checkoutId, {
3048
+ email: 'customer@example.com', // required
2845
3049
  firstName: 'John',
2846
3050
  lastName: 'Doe',
2847
3051
  line1: '123 Main St',
@@ -5392,6 +5596,56 @@ await client.detachModifierGroup(storeId, productId, attachment.id);
5392
5596
 
5393
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.
5394
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
+
5395
5649
  ---
5396
5650
 
5397
5651
  ## Complete Page Examples
@@ -5804,12 +6058,14 @@ export default function CheckoutPage() {
5804
6058
  const [checkout, setCheckout] = useState<Checkout | null>(null);
5805
6059
  const [shippingRates, setShippingRates] = useState<ShippingRate[]>([]);
5806
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');
5807
6063
  const customerLoggedIn = isLoggedIn();
5808
6064
 
5809
- // Form state
5810
- const [email, setEmail] = useState('');
6065
+ // Form state. `email` lives here because setShippingAddress requires it on
6066
+ // every call, logged-in shoppers included.
5811
6067
  const [shippingAddress, setShippingAddress] = useState({
5812
- firstName: '', lastName: '', line1: '', city: '', postalCode: '', country: 'US'
6068
+ email: '', firstName: '', lastName: '', line1: '', city: '', postalCode: '', country: 'US'
5813
6069
  });
5814
6070
 
5815
6071
  useEffect(() => {
@@ -5839,7 +6095,8 @@ export default function CheckoutPage() {
5839
6095
  initCheckout();
5840
6096
  }, []);
5841
6097
 
5842
- const handleSubmit = async (e: React.FormEvent) => {
6098
+ // Phase 1: save the customer and the address, then show the rate picker.
6099
+ const handleAddressSubmit = async (e: React.FormEvent) => {
5843
6100
  e.preventDefault();
5844
6101
  if (!checkout) return;
5845
6102
  setSubmitting(true);
@@ -5847,19 +6104,33 @@ export default function CheckoutPage() {
5847
6104
  try {
5848
6105
  // 1. Set customer info
5849
6106
  await client.setCheckoutCustomer(checkout.id, {
5850
- email,
6107
+ email: shippingAddress.email,
5851
6108
  firstName: shippingAddress.firstName,
5852
6109
  lastName: shippingAddress.lastName,
5853
6110
  });
5854
6111
 
5855
- // 2. Set shipping address
5856
- await client.setShippingAddress(checkout.id, shippingAddress);
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
+ };
5857
6125
 
5858
- // 3. Get and select shipping rate
5859
- const rates = await client.getShippingRates(checkout.id);
5860
- if (rates.length > 0) {
5861
- await client.selectShippingMethod(checkout.id, selectedRate || rates[0].id);
5862
- }
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);
5863
6134
 
5864
6135
  // 4. Complete checkout
5865
6136
  const { orderId } = await client.completeCheckout(checkout.id);
@@ -5878,28 +6149,42 @@ export default function CheckoutPage() {
5878
6149
 
5879
6150
  if (loading) return <div>Loading checkout...</div>;
5880
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
+
5881
6175
  return (
5882
- <form onSubmit={handleSubmit}>
5883
- {!customerLoggedIn && (
5884
- <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" required />
5885
- )}
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 />
5886
6180
  <input value={shippingAddress.firstName} onChange={(e) => setShippingAddress({...shippingAddress, firstName: e.target.value})} placeholder="First Name" required />
5887
6181
  <input value={shippingAddress.lastName} onChange={(e) => setShippingAddress({...shippingAddress, lastName: e.target.value})} placeholder="Last Name" required />
5888
6182
  <input value={shippingAddress.line1} onChange={(e) => setShippingAddress({...shippingAddress, line1: e.target.value})} placeholder="Address" required />
5889
6183
  <input value={shippingAddress.city} onChange={(e) => setShippingAddress({...shippingAddress, city: e.target.value})} placeholder="City" required />
5890
6184
  <input value={shippingAddress.postalCode} onChange={(e) => setShippingAddress({...shippingAddress, postalCode: e.target.value})} placeholder="Postal Code" required />
5891
6185
 
5892
- {shippingRates.length > 0 && (
5893
- <select value={selectedRate || ''} onChange={(e) => setSelectedRate(e.target.value)}>
5894
- {shippingRates.map((rate) => (
5895
- // speedTier for live carrier rates; the merchant's own name for zone rates
5896
- <option key={rate.id} value={rate.id}>{rate.speedTier ? TIER_LABELS[rate.speedTier] : rate.name} - ${rate.price}</option>
5897
- ))}
5898
- </select>
5899
- )}
5900
-
5901
6186
  <button type="submit" disabled={submitting}>
5902
- {submitting ? 'Processing...' : 'Place Order'}
6187
+ {submitting ? 'Loading delivery options...' : 'Continue to delivery'}
5903
6188
  </button>
5904
6189
  </form>
5905
6190
  );
@@ -5909,6 +6194,9 @@ export default function CheckoutPage() {
5909
6194
  > **Key Points:**
5910
6195
  >
5911
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.
5912
6200
  > - Guest session cart is created automatically by `smart*` methods
5913
6201
  > - Call `client.onCheckoutComplete()` after successful payment to clear the session cart
5914
6202
  > - Call `client.syncCartOnLogin()` when a user logs in to merge their guest cart
@@ -5978,6 +6266,7 @@ export default function CheckoutPage() {
5978
6266
  setSubmitting(true);
5979
6267
  try {
5980
6268
  const { rates } = await client.setShippingAddress(checkout.id, {
6269
+ email, // required on every call, even after setCheckoutCustomer
5981
6270
  firstName, lastName,
5982
6271
  line1: address,
5983
6272
  city, postalCode, country,