@brainerce/mcp-server 3.5.0 → 3.6.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/dist/bin/http.js CHANGED
@@ -95,7 +95,7 @@ npm install brainerce
95
95
  import { BrainerceClient } from 'brainerce';
96
96
 
97
97
  export const client = new BrainerceClient({
98
- connectionId: '${connectionId}',
98
+ salesChannelId: '${connectionId}',
99
99
  });
100
100
 
101
101
  // Cart helpers \u2014 save cart ID to localStorage
@@ -197,7 +197,7 @@ async function startCheckout() {
197
197
  ### Full Checkout Flow (Multi-Provider)
198
198
 
199
199
  1. Customer fills cart
200
- 2. Customer optionally applies coupon (cart page)
200
+ 2. Customer optionally applies coupon on cart page \u2192 \`applyCoupon(cartId, code)\`
201
201
  3. Detect payment providers \u2192 \`getPaymentProviders()\`
202
202
  4. Start checkout session (\`startGuestCheckout()\` or \`createCheckout()\`)
203
203
  5. Set shipping address (includes required email) \u2192 \`setShippingAddress()\`
@@ -215,7 +215,7 @@ import { Elements, PaymentElement, useStripe, useElements } from '@stripe/react-
215
215
 
216
216
  function CheckoutPage() {
217
217
  const [paymentData, setPaymentData] = useState<{
218
- clientSecret: string; provider: string; checkoutId: string;
218
+ clientSecret: string; provider: string; checkoutId: string; clientSdk?: { renderType: string };
219
219
  } | null>(null);
220
220
  const [stripePromise, setStripePromise] = useState<ReturnType<typeof loadStripe> | null>(null);
221
221
  const [paypalClientId, setPaypalClientId] = useState<string | null>(null);
@@ -277,7 +277,7 @@ function CheckoutPage() {
277
277
  // saveCard: customerOptedIn, // optional opt-in
278
278
  });
279
279
 
280
- setPaymentData({ clientSecret: paymentIntent.clientSecret, provider: paymentIntent.provider, checkoutId });
280
+ setPaymentData({ clientSecret: paymentIntent.clientSecret, provider: paymentIntent.provider, checkoutId, clientSdk: paymentIntent.clientSdk });
281
281
 
282
282
  // Step 5: Initialize the correct payment provider
283
283
  if (paymentIntent.provider === 'stripe' && stripeProvider) {
@@ -887,6 +887,10 @@ function ProductPage({ product, storeInfo }: { product: Product; storeInfo: Stor
887
887
  ? getVariantPrice(selectedVariant, product.basePrice).toString()
888
888
  : product.salePrice || product.basePrice;
889
889
 
890
+ // \u2705 For catalog cards: use pre-computed priceMin/priceMax instead of iterating variants
891
+ // product.priceMin / product.priceMax are always set for VARIABLE products with explicit variant prices.
892
+ // product.priceVaries is true when the range should be shown as "\u20AA49 \u2013 \u20AA199".
893
+
890
894
  // Build attribute buttons (size, color, etc.)
891
895
  const allOptions = product.variants?.map(v => getVariantOptions(v)) || [];
892
896
  const attrNames = [...new Set(allOptions.flatMap(opts => opts.map(o => o.name)))];
@@ -1101,6 +1105,7 @@ const total = cart.total;
1101
1105
 
1102
1106
  ### Coupons
1103
1107
 
1108
+ **On the cart page** (before checkout is created):
1104
1109
  \`\`\`typescript
1105
1110
  // Apply coupon \u2014 returns updated Cart with discountAmount
1106
1111
  const updatedCart = await client.applyCoupon(cartId, 'SAVE20');
@@ -1108,13 +1113,24 @@ console.log(updatedCart.discountAmount); // "10.00"
1108
1113
  console.log(updatedCart.couponCode); // "SAVE20"
1109
1114
 
1110
1115
  // Remove coupon
1111
- const updatedCart = await client.removeCoupon(cartId);
1116
+ await client.removeCoupon(cartId);
1112
1117
 
1113
1118
  // Calculate totals including discount
1114
1119
  const totals = getCartTotals(cart); // { subtotal, discount, shipping, total }
1115
1120
  \`\`\`
1116
1121
 
1117
- Add a coupon code input on the cart page. Coupons only work on server \`Cart\` (has \`id\` field). Checkout inherits the coupon \u2014 show \`checkout.discountAmount\` and \`checkout.couponCode\` in checkout order summary.
1122
+ **On the checkout page** (after checkout session exists \u2014 ALWAYS use this when checkoutId is available):
1123
+ \`\`\`typescript
1124
+ // Applies to cart AND updates checkout totals in one call
1125
+ const checkout = await client.applyCheckoutCoupon(checkoutId, 'SAVE20');
1126
+ console.log(checkout.discountAmount); // "10.00"
1127
+ console.log(checkout.total); // correctly updated total
1128
+
1129
+ // Remove coupon from checkout
1130
+ await client.removeCheckoutCoupon(checkoutId);
1131
+ \`\`\`
1132
+
1133
+ > \u26A0\uFE0F **Critical:** if a checkout session already exists, ALWAYS use \`applyCheckoutCoupon(checkoutId, code)\`. Using \`applyCoupon(cartId, code)\` after checkout creation does NOT update the checkout total \u2014 payment will charge the original amount. Show \`checkout.discountAmount\` and \`checkout.couponCode\` in the order summary.
1118
1134
 
1119
1135
  ### \u26A0\uFE0F Checkout Order Summary \u2014 Use checkout.lineItems, NOT cart.items!
1120
1136
 
@@ -1539,6 +1555,53 @@ The merchant can hide a group entirely for one variant by setting \`maxOverride:
1539
1555
 
1540
1556
  Allergens (informational chips), scheduled availability windows, nested combos (depth \u2264 3 via \`nestedByModifierId\`), and downsell modifiers (negative \`priceDelta\`) are all built on the same data model. See INTEGRATION-OPTIONAL.md "Restaurant / build-your-own products" for those flows.`;
1541
1557
  }
1558
+ function getProductReviewsSection() {
1559
+ return `## Product Reviews (\u2605 ratings + JSON-LD for SEO)
1560
+
1561
+ Reviews publish immediately on submit. Merchants hide individual reviews via the admin surface \u2014 no PENDING queue.
1562
+
1563
+ \`\`\`typescript
1564
+ // PDP \u2014 list visible reviews
1565
+ const { data, meta } = await client.listProductReviews(productId, { page: 1, limit: 20 });
1566
+ data.forEach(r => render(r.authorName, r.rating, r.body, r.verifiedPurchase));
1567
+
1568
+ // PDP \u2014 submit form
1569
+ await client.submitProductReview(productId, {
1570
+ authorName, authorEmail, rating: 5, body,
1571
+ });
1572
+ \`\`\`
1573
+
1574
+ Each \`Product\` carries denormalized rollups: \`product.avgRating\`, \`product.reviewCount\`. Use these on PLP cards.
1575
+
1576
+ ### JSON-LD (mandatory for SEO stars in Google)
1577
+
1578
+ Emit \`aggregateRating\` ONLY when \`product.reviewCount > 0\`:
1579
+
1580
+ \`\`\`typescript
1581
+ const productJsonLd = {
1582
+ '@context': 'https://schema.org',
1583
+ '@type': 'Product',
1584
+ name: product.name,
1585
+ // \u2026existing fields
1586
+ ...(product.reviewCount > 0 && {
1587
+ aggregateRating: {
1588
+ '@type': 'AggregateRating',
1589
+ ratingValue: product.avgRating,
1590
+ reviewCount: product.reviewCount,
1591
+ bestRating: 5,
1592
+ worstRating: 1,
1593
+ },
1594
+ }),
1595
+ };
1596
+ \`\`\`
1597
+
1598
+ ### Rules
1599
+ - Rating must be an integer 1-5 (DB constraint).
1600
+ - Duplicate from same email \u2192 409 Conflict.
1601
+ - Submit endpoint is throttled to 3 / 60s / IP \u2014 surface a friendly "please wait" on 429.
1602
+ - \`verifiedPurchase\` is derived server-side from DELIVERED orders \u2014 DO NOT try to set it.
1603
+ - Stores with \`reviewsEnabled = false\` return 403 on storefront endpoints.`;
1604
+ }
1542
1605
  function getInventorySection() {
1543
1606
  return `## Inventory, Stock Display & Reservation Countdown
1544
1607
 
@@ -1934,7 +1997,7 @@ subsequent SDK call sends the \`Accept-Language\` header automatically:
1934
1997
  \`\`\`typescript
1935
1998
  import { BrainerceClient } from 'brainerce';
1936
1999
 
1937
- const client = new BrainerceClient({ connectionId: 'vc_...' });
2000
+ const client = new BrainerceClient({ salesChannelId: 'vc_...' });
1938
2001
  client.setLocale('he'); // done \u2014 all calls are now in Hebrew
1939
2002
  \`\`\`
1940
2003
 
@@ -2485,6 +2548,8 @@ function getSectionByTopic(topic, connectionId, currency) {
2485
2548
  return getDiscountsSection();
2486
2549
  case "recommendations":
2487
2550
  return getRecommendationsSection();
2551
+ case "reviews":
2552
+ return getProductReviewsSection();
2488
2553
  case "product-customization-fields":
2489
2554
  return getProductCustomizationFieldsSection();
2490
2555
  case "modifier-groups":
@@ -2557,6 +2622,10 @@ function getSectionByTopic(topic, connectionId, currency) {
2557
2622
  "",
2558
2623
  "---",
2559
2624
  "",
2625
+ getProductReviewsSection(),
2626
+ "",
2627
+ "---",
2628
+ "",
2560
2629
  getDiscountsSection(),
2561
2630
  "",
2562
2631
  "---",
@@ -2608,6 +2677,7 @@ var GET_SDK_DOCS_SCHEMA = {
2608
2677
  "inventory",
2609
2678
  "discounts",
2610
2679
  "recommendations",
2680
+ "reviews",
2611
2681
  "product-customization-fields",
2612
2682
  "tax",
2613
2683
  "critical-rules",
@@ -2651,6 +2721,9 @@ interface Product {
2651
2721
  basePrice: string; // Use parseFloat() for calculations
2652
2722
  salePrice?: string | null;
2653
2723
  costPrice?: string | null;
2724
+ priceMin?: string | null; // Lowest variant price (VARIABLE products). Use for catalog/JSON-LD range display.
2725
+ priceMax?: string | null; // Highest variant price (VARIABLE products).
2726
+ priceVaries?: boolean; // true when variant prices differ \u2014 show "\u20AA49 \u2013 \u20AA199" range.
2654
2727
  status: string;
2655
2728
  type: 'SIMPLE' | 'VARIABLE';
2656
2729
  isDownloadable?: boolean;
@@ -3221,9 +3294,9 @@ var HELPERS_TYPES = `// ---- Helper Functions (import from 'brainerce') ----
3221
3294
  // Price helpers
3222
3295
  function formatPrice(priceString: string | number | undefined | null, options?: { currency?: string; locale?: string; }): string;
3223
3296
  function getProductPrice(product: Pick<Product, 'basePrice' | 'salePrice'>): number;
3224
- function getProductPriceInfo(product: Pick<Product, 'basePrice' | 'salePrice'>): {
3297
+ function getProductPriceInfo(product: Pick<Product, 'basePrice' | 'salePrice' | 'discount' | 'priceMin' | 'priceVaries'>): {
3225
3298
  price: number; originalPrice: number; isOnSale: boolean; discountAmount: number; discountPercent: number;
3226
- };
3299
+ }; // Falls back to priceMin when basePrice=0 (VARIABLE products)
3227
3300
  function getVariantPrice(variant: Pick<ProductVariant, 'price' | 'salePrice'>, productBasePrice: string): number;
3228
3301
 
3229
3302
  // Cart helpers
@@ -3336,6 +3409,37 @@ interface CartBundleOffer {
3336
3409
  totalOriginalPrice: string;
3337
3410
  totalDiscountedPrice: string;
3338
3411
  }`;
3412
+ var REVIEWS_TYPES = `// ---- Product Reviews ----
3413
+
3414
+ interface ProductReview {
3415
+ id: string;
3416
+ productId: string;
3417
+ authorName: string; // max 100 chars
3418
+ rating: number; // integer 1-5
3419
+ body: string | null; // plain text, max 5000 chars
3420
+ verifiedPurchase: boolean; // derived server-side from DELIVERED orders
3421
+ hiddenAt?: string | null; // ISO datetime; admin responses only \u2014 null = visible
3422
+ createdAt: string; // ISO datetime
3423
+ }
3424
+
3425
+ interface ProductReviewAdmin extends ProductReview {
3426
+ customerId: string | null;
3427
+ authorEmail: string | null;
3428
+ orderId: string | null;
3429
+ updatedAt: string;
3430
+ }
3431
+
3432
+ interface SubmitProductReviewInput {
3433
+ authorName: string;
3434
+ authorEmail?: string; // recommended \u2014 used for guest dedupe + verified-purchase derivation
3435
+ rating: number; // 1-5
3436
+ body?: string; // optional
3437
+ }
3438
+
3439
+ // Each Product carries denormalized review stats:
3440
+ // product.avgRating: number // 0 when reviewCount is 0
3441
+ // product.reviewCount: number // count of visible (hiddenAt IS NULL) reviews
3442
+ `;
3339
3443
  var INQUIRIES_TYPES = `// ---- Contact Inquiries & Forms ----
3340
3444
 
3341
3445
  // Two shapes \u2014 legacy and flexible. The two may be mixed; \`fields\` wins on key collision.
@@ -3579,6 +3683,7 @@ var TYPES_BY_DOMAIN = {
3579
3683
  payments: PAYMENTS_TYPES,
3580
3684
  helpers: HELPERS_TYPES,
3581
3685
  inquiries: INQUIRIES_TYPES,
3686
+ reviews: REVIEWS_TYPES,
3582
3687
  "modifier-groups": MODIFIER_GROUPS_TYPES
3583
3688
  };
3584
3689
  function getTypesByDomain(domain) {
@@ -3609,6 +3714,7 @@ var GET_TYPE_DEFINITIONS_SCHEMA = {
3609
3714
  "payments",
3610
3715
  "helpers",
3611
3716
  "inquiries",
3717
+ "reviews",
3612
3718
  "all"
3613
3719
  ]).describe(
3614
3720
  'The domain of types to retrieve. Use "helpers" for helper function signatures and common types like StoreInfo, PaginatedResponse.'
@@ -3725,7 +3831,7 @@ function selectVariant(product: Product, selections: Record<string, string>) {
3725
3831
  import { client } from './brainerce';
3726
3832
 
3727
3833
  // 1. Submit the address + email. Email is REQUIRED on the DTO.
3728
- const { shippingRates } = await client.setShippingAddress(checkoutId, {
3834
+ const { checkout, rates } = await client.setShippingAddress(checkoutId, {
3729
3835
  email,
3730
3836
  firstName,
3731
3837
  lastName,
@@ -3736,10 +3842,11 @@ const { shippingRates } = await client.setShippingAddress(checkoutId, {
3736
3842
  postalCode,
3737
3843
  country,
3738
3844
  });
3845
+ // rates = available shipping rates; checkout = updated checkout object
3739
3846
 
3740
3847
  // 2. Let the customer pick one of the returned rates.
3741
- const chosen = shippingRates[0]; // user selection
3742
- await client.setShippingMethod(checkoutId, chosen.id);
3848
+ const chosen = rates[0]; // user selection
3849
+ await client.selectShippingMethod(checkoutId, chosen.id);
3743
3850
 
3744
3851
  // 3. Re-fetch the checkout to get updated totals (tax may change after address).
3745
3852
  const checkout = await client.getCheckout(checkoutId);
@@ -3793,21 +3900,21 @@ if (providers.length === 0) {
3793
3900
  }
3794
3901
 
3795
3902
  const active = providers[0];`,
3796
- "checkout-stripe-confirm": `// Stripe confirm flow. Requires @stripe/stripe-js and @stripe/react-stripe-js
3797
- // (or the vanilla Stripe.js browser SDK).
3903
+ "checkout-stripe-confirm": `// Stripe confirm flow. Requires @stripe/stripe-js.
3798
3904
  import { loadStripe } from '@stripe/stripe-js';
3799
3905
  import { client } from './brainerce';
3800
3906
 
3801
3907
  const stripe = await loadStripe(stripePublicKey);
3802
3908
  if (!stripe) throw new Error('Stripe failed to load');
3803
3909
 
3804
- // The SDK returns a clientSecret via the provider's config after setPaymentMethod.
3805
- const { clientSecret } = await client.setPaymentMethod(checkoutId, {
3806
- providerId: stripeProvider.id,
3910
+ // Create a payment intent \u2014 returns clientSecret for Stripe Elements.
3911
+ const intent = await client.createPaymentIntent(checkoutId, {
3912
+ successUrl: \`\${window.location.origin}/order-confirmation?checkoutId=\${checkoutId}\`,
3913
+ cancelUrl: \`\${window.location.origin}/checkout?error=cancelled\`,
3807
3914
  });
3808
3915
 
3809
- // Use Stripe Elements to collect the card, then confirm:
3810
- const result = await stripe.confirmCardPayment(clientSecret, {
3916
+ // Use Stripe Elements to confirm:
3917
+ const result = await stripe.confirmCardPayment(intent.clientSecret, {
3811
3918
  payment_method: { card: cardElement },
3812
3919
  });
3813
3920
 
@@ -3816,21 +3923,21 @@ if (result.error) {
3816
3923
  return;
3817
3924
  }
3818
3925
 
3819
- // Success \u2014 redirect to your confirmation page with checkoutId in the URL.
3926
+ // Success \u2014 redirect to confirmation page.
3820
3927
  // The confirmation page runs handlePaymentSuccess + waitForOrder.
3821
3928
  window.location.assign(\`/order-confirmation?checkoutId=\${checkoutId}\`);`,
3822
3929
  "checkout-paypal-confirm": `// PayPal confirm flow. Use the PayPal JS SDK (render a PayPal button component).
3823
3930
  import { client } from './brainerce';
3824
3931
 
3825
- // When the PayPal button's onApprove fires with the PayPal order ID:
3826
- async function onPayPalApprove(paypalOrderId: string) {
3827
- await client.setPaymentMethod(checkoutId, {
3828
- providerId: paypalProvider.id,
3829
- providerData: { paypalOrderId },
3830
- });
3932
+ // Create a payment intent first \u2014 for PayPal this returns the PayPal order ID.
3933
+ const intent = await client.createPaymentIntent(checkoutId, {
3934
+ successUrl: \`\${window.location.origin}/order-confirmation?checkoutId=\${checkoutId}\`,
3935
+ cancelUrl: \`\${window.location.origin}/checkout?error=cancelled\`,
3936
+ });
3831
3937
 
3832
- // PayPal confirmation \u2014 the SDK captures the order server-side.
3833
- // After this resolves, redirect to confirmation.
3938
+ // Render a PayPal button using intent.clientSdk (provider SDK config).
3939
+ // When the PayPal button's onApprove fires, redirect to confirmation:
3940
+ function onPayPalApprove() {
3834
3941
  window.location.assign(\`/order-confirmation?checkoutId=\${checkoutId}\`);
3835
3942
  }`,
3836
3943
  "checkout-sandbox-confirm": `// Sandbox payment \u2014 store has sandboxPaymentsEnabled=true. No real charge.
@@ -3873,7 +3980,7 @@ async function registerAndMaybeVerify(input: {
3873
3980
  firstName: string;
3874
3981
  lastName: string;
3875
3982
  }) {
3876
- const result = await client.register(input);
3983
+ const result = await client.registerCustomer(input);
3877
3984
  if (result.requiresVerification) {
3878
3985
  // Route the user to your verify-email UI. Do NOT treat them as logged in.
3879
3986
  return { state: 'needs-verification' as const };
@@ -3894,7 +4001,7 @@ async function resendCode() {
3894
4001
  import { client } from './brainerce';
3895
4002
 
3896
4003
  async function login(email: string, password: string) {
3897
- const result = await client.login({ email, password });
4004
+ const result = await client.loginCustomer(email, password);
3898
4005
  if (result.requiresVerification) {
3899
4006
  return { state: 'needs-verification' as const };
3900
4007
  }
@@ -3929,25 +4036,37 @@ async function submitReset(token: string, newPassword: string) {
3929
4036
  "oauth-redirect-and-callback": `// OAuth sign-in. Redirect flow, NOT popup.
3930
4037
  import { client } from './brainerce';
3931
4038
 
3932
- // On your login / register UI:
3933
- const providers = await client.getAvailableOAuthProviders();
3934
- // Render one button per provider. On click, redirect the browser:
3935
- for (const p of providers) {
3936
- // button.onclick = () => window.location.assign(p.authorizationUrl)
4039
+ // Step 1: Get available provider names (strings: 'GOOGLE', 'FACEBOOK', 'GITHUB')
4040
+ const { providers } = await client.getAvailableOAuthProviders();
4041
+
4042
+ // Step 2: For each provider, fetch the authorization URL, then redirect.
4043
+ // Call this when the user clicks a provider button:
4044
+ async function redirectToOAuth(provider: string) {
4045
+ const { authorizationUrl } = await client.getOAuthAuthorizeUrl(provider, {
4046
+ redirectUrl: \`\${window.location.origin}/auth/callback\`,
4047
+ });
4048
+ window.location.href = authorizationUrl; // full-page redirect, NOT a popup
3937
4049
  }
3938
4050
 
3939
- // On your OAuth callback route (SERVER-SIDE / BFF):
3940
- // 1. Read token + oauth_success (or oauth_error) from query params.
3941
- // 2. If oauth_error: redirect to login with the error message.
3942
- // 3. If oauth_success: exchange the token for a session cookie (your BFF),
3943
- // then redirect the browser to /account or wherever.
3944
- // Do NOT allow the token to land in browser history as a query param.`,
4051
+ // Step 3: On your /auth/callback route, read token from URL params:
4052
+ const params = new URLSearchParams(window.location.search);
4053
+ const oauthError = params.get('oauth_error');
4054
+ const token = params.get('token');
4055
+ if (oauthError) {
4056
+ window.location.href = '/login?error=' + encodeURIComponent(oauthError);
4057
+ } else if (token) {
4058
+ client.setCustomerToken(token);
4059
+ window.location.href = '/account';
4060
+ }`,
3945
4061
  "coupon-apply-and-remove": `// Coupon input. Build the UI even if no coupons exist today \u2014 it auto-hides.
4062
+ // IMPORTANT: use applyCheckoutCoupon when a checkoutId is available (checkout page).
4063
+ // Using applyCoupon after checkout creation does NOT update the checkout total.
3946
4064
  import { client } from './brainerce';
3947
4065
 
3948
- async function applyCoupon(code: string) {
4066
+ // On cart page (no checkout session yet)
4067
+ async function applyCouponToCart(cartId: string, code: string) {
3949
4068
  try {
3950
- const cart = await client.applyCoupon(code);
4069
+ const cart = await client.applyCoupon(cartId, code);
3951
4070
  return { ok: true, cart };
3952
4071
  } catch (err) {
3953
4072
  // Invalid / expired / minimum not met. Surface the specific error.
@@ -3955,17 +4074,30 @@ async function applyCoupon(code: string) {
3955
4074
  }
3956
4075
  }
3957
4076
 
3958
- async function removeCoupon() {
3959
- const cart = await client.removeCoupon();
3960
- return cart;
4077
+ async function removeCouponFromCart(cartId: string) {
4078
+ return client.removeCoupon(cartId);
4079
+ }
4080
+
4081
+ // On checkout page (checkout session already exists \u2014 always prefer this)
4082
+ async function applyCouponToCheckout(checkoutId: string, code: string) {
4083
+ try {
4084
+ const checkout = await client.applyCheckoutCoupon(checkoutId, code);
4085
+ return { ok: true, checkout }; // checkout.total is already updated
4086
+ } catch (err) {
4087
+ return { ok: false, error: (err as Error).message };
4088
+ }
4089
+ }
4090
+
4091
+ async function removeCouponFromCheckout(checkoutId: string) {
4092
+ return client.removeCheckoutCoupon(checkoutId);
3961
4093
  }`,
3962
4094
  "reservation-countdown": `// Reservation countdown. Use the SDK's expiry timestamp \u2014 do NOT invent
3963
4095
  // your own timer state.
3964
4096
  import { client } from './brainerce';
3965
4097
 
3966
- function getRemainingMs(cart: { reservationExpiresAt?: string | null }): number {
3967
- if (!cart.reservationExpiresAt) return 0;
3968
- const expiry = new Date(cart.reservationExpiresAt).getTime();
4098
+ function getRemainingMs(cart: { reservation?: { expiresAt?: string } }): number {
4099
+ if (!cart.reservation?.expiresAt) return 0;
4100
+ const expiry = new Date(cart.reservation.expiresAt).getTime();
3969
4101
  return Math.max(0, expiry - Date.now());
3970
4102
  }
3971
4103
 
@@ -3992,8 +4124,8 @@ function onSearchChange(query: string, onResults: (items: unknown[]) => void) {
3992
4124
  return;
3993
4125
  }
3994
4126
  searchTimer = setTimeout(async () => {
3995
- const results = await client.searchProducts(query);
3996
- onResults(results);
4127
+ const { products } = await client.getSearchSuggestions(query);
4128
+ onResults(products);
3997
4129
  }, 300);
3998
4130
  }`,
3999
4131
  "i18n-set-locale": `// i18n \u2014 only if get-store-capabilities reports i18n.enabled.
@@ -4350,19 +4482,13 @@ import { client } from './brainerce-client';
4350
4482
 
4351
4483
  interface CheckoutOptions {
4352
4484
  cartId: string;
4353
- shippingAddress: object;
4354
- shippingMethodId: string;
4355
- paymentProviderId: string;
4356
4485
  }
4357
4486
 
4358
4487
  async function createCheckoutSafe(opts: CheckoutOptions) {
4359
4488
  try {
4360
- return await client.createCheckout({
4361
- cartId: opts.cartId,
4362
- shippingAddress: opts.shippingAddress,
4363
- shippingMethodId: opts.shippingMethodId,
4364
- paymentProviderId: opts.paymentProviderId,
4365
- });
4489
+ // createCheckout only takes cartId (+ optional customerId, selectedItemIds).
4490
+ // Shipping address, shipping method, and payment are set via separate calls.
4491
+ return await client.createCheckout({ cartId: opts.cartId });
4366
4492
  } catch (err: unknown) {
4367
4493
  if (!isApiError(err, 'PRICE_DRIFT')) throw err;
4368
4494
 
@@ -4492,6 +4618,93 @@ function renderCartItem(item: CartItem): string {
4492
4618
  // multiply unitPrice \xD7 quantity directly.
4493
4619
  function lineTotal(item: CartItem): number {
4494
4620
  return item.unitPrice * item.quantity;
4621
+ }`,
4622
+ "product-reviews": `// PDP: list visible reviews + submit form + JSON-LD aggregateRating
4623
+ import { client } from '@/lib/brainerce';
4624
+ import type { Product, ProductReview, SubmitProductReviewInput } from 'brainerce';
4625
+
4626
+ export async function ProductReviewsSection({ product }: { product: Product }) {
4627
+ const { data } = await client.listProductReviews(product.id, { page: 1, limit: 20 });
4628
+
4629
+ return (
4630
+ <section>
4631
+ <h2>Reviews ({product.reviewCount ?? 0})</h2>
4632
+ {data.length === 0 && <p>No reviews yet \u2014 be the first.</p>}
4633
+ {data.map((r) => (
4634
+ <article key={r.id}>
4635
+ <strong>{r.authorName}</strong>
4636
+ {r.verifiedPurchase && <span> \xB7 Verified</span>}
4637
+ <div>{'\u2605'.repeat(r.rating)}{'\u2606'.repeat(5 - r.rating)}</div>
4638
+ {r.body && <p>{r.body}</p>}
4639
+ </article>
4640
+ ))}
4641
+ <ReviewForm productId={product.id} />
4642
+ </section>
4643
+ );
4644
+ }
4645
+
4646
+ // JSON-LD \u2014 emit aggregateRating only when there is data
4647
+ function productJsonLd(product: Product, url: string) {
4648
+ return {
4649
+ '@context': 'https://schema.org',
4650
+ '@type': 'Product',
4651
+ name: product.name,
4652
+ image: product.images?.[0]?.url,
4653
+ url,
4654
+ sku: product.sku ?? product.id,
4655
+ ...(product.reviewCount && product.reviewCount > 0 && {
4656
+ aggregateRating: {
4657
+ '@type': 'AggregateRating',
4658
+ ratingValue: product.avgRating,
4659
+ reviewCount: product.reviewCount,
4660
+ bestRating: 5,
4661
+ worstRating: 1,
4662
+ },
4663
+ }),
4664
+ };
4665
+ }
4666
+
4667
+ // Client-side form
4668
+ 'use client';
4669
+ import { useState } from 'react';
4670
+
4671
+ function ReviewForm({ productId }: { productId: string }) {
4672
+ const [rating, setRating] = useState(5);
4673
+ const [body, setBody] = useState('');
4674
+ const [name, setName] = useState('');
4675
+ const [email, setEmail] = useState('');
4676
+ const [error, setError] = useState<string | null>(null);
4677
+
4678
+ async function submit(e: React.FormEvent) {
4679
+ e.preventDefault();
4680
+ setError(null);
4681
+ try {
4682
+ await client.submitProductReview(productId, {
4683
+ authorName: name,
4684
+ authorEmail: email,
4685
+ rating,
4686
+ body: body || undefined,
4687
+ });
4688
+ window.location.reload();
4689
+ } catch (err: any) {
4690
+ if (err?.status === 409) setError("You've already reviewed this product.");
4691
+ else if (err?.status === 429) setError('Too many submissions. Please wait a moment.');
4692
+ else setError('Could not submit review. Please try again.');
4693
+ }
4694
+ }
4695
+
4696
+ return (
4697
+ <form onSubmit={submit}>
4698
+ <input value={name} onChange={(e) => setName(e.target.value)} placeholder="Your name" required maxLength={100} />
4699
+ <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" />
4700
+ <select value={rating} onChange={(e) => setRating(Number(e.target.value))}>
4701
+ {[5,4,3,2,1].map(n => <option key={n} value={n}>{'\u2605'.repeat(n)}</option>)}
4702
+ </select>
4703
+ <textarea value={body} onChange={(e) => setBody(e.target.value)} maxLength={5000} placeholder="Tell us what you think (optional)" />
4704
+ <button type="submit">Submit review</button>
4705
+ {error && <p role="alert">{error}</p>}
4706
+ </form>
4707
+ );
4495
4708
  }`
4496
4709
  };
4497
4710
  async function handleGetCodeExample(args) {
@@ -5037,21 +5250,21 @@ var FLOWS = {
5037
5250
  1. **Collect address + email.** Build a form that captures customer email, billing address, shipping address (with \`line1\`, \`line2\`, \`city\`, \`region\`, \`postalCode\`, \`country\`). \`email\` is REQUIRED on \`SetShippingAddressDto\`.
5038
5251
  2. **Submit the address to get shipping rates.**
5039
5252
  \`\`\`ts
5040
- const { shippingRates } = await client.setShippingAddress(checkoutId, {
5253
+ const { checkout, rates } = await client.setShippingAddress(checkoutId, {
5041
5254
  email,
5042
5255
  firstName,
5043
5256
  lastName,
5044
5257
  line1, line2, city, region, postalCode, country,
5045
5258
  });
5046
5259
  \`\`\`
5047
- The response includes the available shipping rates for that address + the store's zones.
5260
+ \`rates\` = available shipping rates for that address + the store's zones. \`checkout\` = updated checkout object.
5048
5261
  3. **Let the customer pick a rate**, then persist the selection:
5049
5262
  \`\`\`ts
5050
- await client.setShippingMethod(checkoutId, rateId);
5263
+ await client.selectShippingMethod(checkoutId, rateId);
5051
5264
  \`\`\`
5052
- 4. **Fetch payment providers for this checkout:**
5265
+ 4. **Fetch payment providers:**
5053
5266
  \`\`\`ts
5054
- const providers = await client.getPaymentProviders(checkoutId);
5267
+ const providers = await client.getPaymentProviders();
5055
5268
  \`\`\`
5056
5269
  The response tells you which providers are configured (Stripe, Grow, PayPal, Sandbox) and how to render each. Each provider has a \`renderType\` telling you whether to show a Stripe Elements form, a redirect button, a PayPal button, or a sandbox "complete test order" button.
5057
5270
  5. **Confirm payment using the provider's recommended flow.** For Stripe: Stripe Elements \u2192 \`stripe.confirmCardPayment\` using the clientSecret returned by the SDK. For sandbox payments: call \`completeGuestCheckout(checkoutId)\` directly. For PayPal/Grow: follow the redirect and handle the return on your confirmation page.
@@ -5067,28 +5280,28 @@ Never bypass these steps. Never call \`submitGuestOrder\` / \`createOrder\` \u20
5067
5280
  "auth-register": {
5068
5281
  title: "Registration",
5069
5282
  body: `1. **Collect** email, password, first name, last name. Enforce strong passwords client-side: 8+ chars, upper, lower, number, special.
5070
- 2. **Call register:**
5283
+ 2. **Call registerCustomer:**
5071
5284
  \`\`\`ts
5072
- const result = await client.register({ email, password, firstName, lastName });
5285
+ const result = await client.registerCustomer({ email, password, firstName, lastName });
5073
5286
  \`\`\`
5074
5287
  3. **Branch on \`result.requiresVerification\`:**
5075
- - If \`true\`: route the user to your verify-email UI. Do NOT treat them as logged in yet.
5076
- - If \`false\`: the user is logged in. Store the token via your BFF session and route to the account area.
5288
+ - If \`true\`: store the token temporarily (e.g. sessionStorage), route the user to your verify-email UI. Do NOT treat them as logged in yet.
5289
+ - If \`false\`: call \`client.setCustomerToken(result.token)\` and route to the account area.
5077
5290
  4. **On the verify-email step:** collect a 6-digit code and call \`client.verifyEmail(code)\`. Offer a "resend code" button wired to \`client.resendVerificationEmail()\`.
5078
- 5. **After verifyEmail resolves:** the user is now logged in. Persist the session via your BFF and route to the account area.
5291
+ 5. **After verifyEmail resolves:** call \`client.setCustomerToken(result.token)\` \u2014 the user is now logged in \u2014 then route to the account area.
5079
5292
 
5080
5293
  Build the verify-email step EVEN IF the store currently has verification disabled. It auto-hides; store owners enable it later.`
5081
5294
  },
5082
5295
  "auth-login": {
5083
5296
  title: "Login",
5084
5297
  body: `1. **Collect** email + password.
5085
- 2. **Call login:**
5298
+ 2. **Call loginCustomer:**
5086
5299
  \`\`\`ts
5087
- const result = await client.login({ email, password });
5300
+ const result = await client.loginCustomer(email, password);
5088
5301
  \`\`\`
5089
5302
  3. **Branch on \`result.requiresVerification\`:**
5090
5303
  - If \`true\`: route to verify-email. The user must complete verification before accessing account features.
5091
- - If \`false\`: persist the session via your BFF and route to the previous page (or account area).
5304
+ - If \`false\`: call \`client.setCustomerToken(result.token)\` and route to the previous page (or account area).
5092
5305
  4. **Offer OAuth buttons** from \`client.getAvailableOAuthProviders()\`. Render a placeholder region even when no providers are returned \u2014 the region auto-hides today and shows buttons the moment a provider is enabled in the dashboard.
5093
5306
  5. **On error**, render the specific message (invalid credentials, rate limited, account disabled) \u2014 never swallow.`
5094
5307
  },
@@ -5113,15 +5326,24 @@ Build both steps EVEN IF the store has no email provider configured today \u2014
5113
5326
  },
5114
5327
  oauth: {
5115
5328
  title: "OAuth sign-in",
5116
- body: `1. **Render available providers:**
5329
+ body: `1. **Get available provider names:**
5117
5330
  \`\`\`ts
5118
- const providers = await client.getAvailableOAuthProviders();
5331
+ const { providers } = await client.getAvailableOAuthProviders();
5332
+ // providers = ['GOOGLE', 'FACEBOOK', 'GITHUB'] (strings, not objects with authorizationUrl)
5333
+ \`\`\`
5334
+ 2. **For each provider, fetch the authorization URL:**
5335
+ \`\`\`ts
5336
+ const { authorizationUrl } = await client.getOAuthAuthorizeUrl(provider, {
5337
+ redirectUrl: \`\${window.location.origin}/auth/callback\`,
5338
+ });
5339
+ window.location.href = authorizationUrl; // full-page redirect, NOT a popup
5119
5340
  \`\`\`
5120
- Each provider has \`{ provider: 'GOOGLE' | 'FACEBOOK' | 'GITHUB', authorizationUrl }\`.
5121
- 2. **Build a button for each** that redirects the browser to \`authorizationUrl\`. Do NOT open a popup; the full-page redirect is required.
5122
- 3. **On the provider's side** the user authenticates, and the provider redirects back to your OAuth callback URL with \`token\` + \`oauth_success\` (or \`oauth_error\`) query params.
5123
- 4. **The callback handler MUST run server-side (BFF).** Extract the token from the query params, exchange it for a session cookie via your BFF, then redirect the browser to the account area. Never let the token land in browser history as a query param or localStorage.
5124
- 5. **On \`oauth_error\`:** redirect to login with an error message.
5341
+ 3. **On the callback page** the URL contains \`token\` + \`oauth_success\` (or \`oauth_error\`) query params. Extract and apply the token:
5342
+ \`\`\`ts
5343
+ const token = new URLSearchParams(location.search).get('token');
5344
+ if (token) client.setCustomerToken(token); // then redirect to account
5345
+ \`\`\`
5346
+ 4. **On \`oauth_error\`:** redirect to login with an error message.
5125
5347
 
5126
5348
  Build the OAuth button region AND the callback handler even when no providers are configured. They auto-hide.`
5127
5349
  },
@@ -5143,7 +5365,7 @@ Build the OAuth button region AND the callback handler even when no providers ar
5143
5365
 
5144
5366
  - **Cart ID persistence:** the SDK stores the cart ID across reloads. You do not need to write cart-to-localStorage code yourself.
5145
5367
  - **Reads:** \`client.getCart()\` returns the current cart. Call it on mount in your cart UI and on any page that shows a cart count (header).
5146
- - **Writes:** use \`client.addToCart\`, \`client.updateCartItem\`, \`client.removeCartItem\`, \`client.applyCoupon\`, \`client.removeCoupon\`. After each mutation the SDK returns the updated cart.
5368
+ - **Writes:** use \`client.addToCart\`, \`client.updateCartItem\`, \`client.removeCartItem\`, \`client.applyCoupon\`, \`client.removeCoupon\`. After each mutation the SDK returns the updated cart. On the **checkout page** use \`client.applyCheckoutCoupon(checkoutId, code)\` / \`client.removeCheckoutCoupon(checkoutId)\` \u2014 these update checkout totals atomically. Never use \`applyCoupon\` after a checkout session exists.
5147
5369
  - **Totals:** call \`getCartTotals(cart)\` \u2014 do NOT read \`cart.total\`. The helper understands taxes, shipping, and discounts.
5148
5370
  - **\`smartGetCart()\`** returns \`CartWithIncludes\` (extends \`Cart\`). All carts are server-side. Pass \`{ include: ['recommendations', 'upgrades', 'bundles'] }\` to fetch extras in one request.
5149
5371
  - **NEVER mutate cart state outside SDK helpers.** Any hand-rolled cart update risks desync with the reservation timer and the checkout flow.`
@@ -5275,7 +5497,7 @@ var FEATURES = [
5275
5497
  id: "browse-products",
5276
5498
  title: "Browse, filter, and search products",
5277
5499
  description: "Users can list products with pagination, apply category / price / attribute / custom-field filters, sort by name / price / newest, and run a search with autocomplete. Custom-field filters surface metafield definitions whose filterable=true (SELECT, MULTI_SELECT, BOOLEAN). Must handle empty states and loading states.",
5278
- sdk: "client.getProducts({ page, limit, filters, sort, metafields }), client.getPublicMetafieldDefinitions(), client.searchProducts(query)",
5500
+ sdk: "client.getProducts({ page, limit, filters, sort, metafields }), client.getPublicMetafieldDefinitions(), client.getSearchSuggestions(query)",
5279
5501
  mandatory: "mandatory"
5280
5502
  },
5281
5503
  {
@@ -5285,6 +5507,13 @@ var FEATURES = [
5285
5507
  sdk: "client.getProductBySlug(slug). Helpers: getProductPriceInfo, getVariantPrice, getStockStatus, getVariantOptions, getProductSwatches, getDescriptionContent, getProductMetafieldValue.",
5286
5508
  mandatory: "mandatory"
5287
5509
  },
5510
+ {
5511
+ id: "product-reviews",
5512
+ title: "Display + collect product reviews with stars + JSON-LD",
5513
+ description: 'On the PDP show a Reviews section: list of customer reviews with star rating, author name, verified-purchase badge, body, and date. Include a submit form (name + email + 1-5 stars + optional body). On PLP cards show \u2605 avgRating and (reviewCount). Emit Product JSON-LD with aggregateRating ONLY when product.reviewCount > 0 \u2014 this is what unlocks the star rich snippet in Google. Reviews publish immediately; the merchant moderates from the admin surface. Rate-limited to 3 submits / 60s / IP. Show a friendly "you already reviewed this" message on 409.',
5514
+ sdk: "client.listProductReviews(productId), client.submitProductReview(productId, { authorName, authorEmail, rating, body }). Product.avgRating + Product.reviewCount are denormalized rollups returned on every product fetch.",
5515
+ mandatory: "mandatory"
5516
+ },
5288
5517
  {
5289
5518
  id: "product-customization-fields",
5290
5519
  title: "Render buyer-input customization fields on the product page",
@@ -5305,7 +5534,7 @@ var FEATURES = [
5305
5534
  id: "cart-coupons",
5306
5535
  title: "Apply and remove coupon codes",
5307
5536
  description: "Users can enter a coupon code, see it applied to the cart, see the discount amount, and remove it. Build the UI even if no coupons are configured today \u2014 it auto-hides.",
5308
- sdk: "client.applyCoupon(code), client.removeCoupon()",
5537
+ sdk: "client.applyCoupon(cartId, code), client.removeCoupon(cartId) \u2014 on cart page. client.applyCheckoutCoupon(checkoutId, code), client.removeCheckoutCoupon(checkoutId) \u2014 on checkout page (use when checkoutId exists)",
5309
5538
  mandatory: "mandatory",
5310
5539
  capabilityFlag: "hasCoupons",
5311
5540
  whenDisabledNote: "Store has no coupons configured today. Build the UI anyway \u2014 it auto-hides until the store owner creates a coupon."
@@ -5322,7 +5551,7 @@ var FEATURES = [
5322
5551
  id: "checkout",
5323
5552
  title: "Complete a full checkout end-to-end",
5324
5553
  description: "Users can enter their address, pick a shipping rate, pick a payment provider, pay, and land on a confirmation page showing the real order. Displays checkout.lineItems (not cart.items) on the summary.",
5325
- sdk: "client.setShippingAddress, client.setShippingMethod, client.getPaymentProviders, provider-specific confirm, client.handlePaymentSuccess, client.waitForOrder",
5554
+ sdk: "client.setShippingAddress (returns { checkout, rates }), client.selectShippingMethod, client.getPaymentProviders(), provider-specific confirm, client.handlePaymentSuccess, client.waitForOrder",
5326
5555
  flowRef: "checkout",
5327
5556
  mandatory: "mandatory"
5328
5557
  },
@@ -5338,7 +5567,7 @@ var FEATURES = [
5338
5567
  id: "register",
5339
5568
  title: "Register a new account with email verification",
5340
5569
  description: 'Users can create an account (email, password, first + last name). Handles the requiresVerification branch by routing to an email-verification step. Verification step collects a 6-digit code and has a "resend code" action.',
5341
- sdk: "client.register({email, password, firstName, lastName}), client.verifyEmail(code), client.resendVerificationEmail()",
5570
+ sdk: "client.registerCustomer({email, password, firstName, lastName}), client.verifyEmail(code), client.resendVerificationEmail()",
5342
5571
  flowRef: "auth-register",
5343
5572
  mandatory: "mandatory",
5344
5573
  capabilityFlag: "oauth",
@@ -5348,7 +5577,7 @@ var FEATURES = [
5348
5577
  id: "login",
5349
5578
  title: "Log in with email / password and handle verification branch",
5350
5579
  description: "Users can log in. The login form handles the requiresVerification branch by routing to the verify-email step. Specific errors render (bad credentials, rate limited, disabled account).",
5351
- sdk: "client.login({email, password})",
5580
+ sdk: "client.loginCustomer(email, password)",
5352
5581
  flowRef: "auth-login",
5353
5582
  mandatory: "mandatory"
5354
5583
  },
@@ -5381,14 +5610,14 @@ var FEATURES = [
5381
5610
  id: "header",
5382
5611
  title: "Global header with cart count and search",
5383
5612
  description: "A header visible across the experience with the store logo, navigation, a cart icon with live item count, a search input with autocomplete (debounced ~300ms, 2-char minimum), and a login / account action. Below the header, discount banners render when active.",
5384
- sdk: "client.getCart() for the count, client.searchProducts(query) for autocomplete",
5613
+ sdk: "client.getCart() for the count, client.getSearchSuggestions(query) for autocomplete",
5385
5614
  mandatory: "mandatory"
5386
5615
  },
5387
5616
  {
5388
5617
  id: "discount-banners",
5389
5618
  title: "Show active discount banners and badges",
5390
5619
  description: "Active discount rules render as banners (below the header) and as badges on product cards / detail pages. Build the UI even if the store has no active discounts today \u2014 it auto-hides.",
5391
- sdk: "client.getDiscountBanners(), getProductDiscountBadge(product)",
5620
+ sdk: "client.getDiscountBanners(), client.getProductDiscountBadge(productId)",
5392
5621
  mandatory: "mandatory",
5393
5622
  capabilityFlag: "hasDiscountRules",
5394
5623
  whenDisabledNote: "Store has no discount rules active. Build the banner and badge components anyway \u2014 they auto-hide."