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 +321 -32
- package/dist/index.d.mts +243 -182
- package/dist/index.d.ts +243 -182
- package/dist/index.js +211 -246
- package/dist/index.mjs +211 -246
- package/package.json +1 -1
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.
|
|
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
|
-
|
|
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. `
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5859
|
-
|
|
5860
|
-
|
|
5861
|
-
|
|
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={
|
|
5883
|
-
{
|
|
5884
|
-
|
|
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 ? '
|
|
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,
|