@brainerce/mcp-server 3.4.0 → 3.5.1

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);
@@ -267,12 +267,17 @@ function CheckoutPage() {
267
267
  }
268
268
 
269
269
  // Step 4: Create payment intent \u2014 returns provider type!
270
+ // Pass saveCard: true when the customer ticked "save my card for next time" \u2014
271
+ // only honored for logged-in customers (not guests). The vaulted card then
272
+ // appears in client.listSavedPaymentMethods(storeId, customerId) and can be
273
+ // charged off-session via subscription / one-click checkout flows.
270
274
  const paymentIntent = await client.createPaymentIntent(checkoutId, {
271
275
  successUrl: \`\${window.location.origin}/order-confirmation?checkout_id=\${checkoutId}\`,
272
276
  cancelUrl: \`\${window.location.origin}/checkout?error=payment_cancelled\`,
277
+ // saveCard: customerOptedIn, // optional opt-in
273
278
  });
274
279
 
275
- setPaymentData({ clientSecret: paymentIntent.clientSecret, provider: paymentIntent.provider, checkoutId });
280
+ setPaymentData({ clientSecret: paymentIntent.clientSecret, provider: paymentIntent.provider, checkoutId, clientSdk: paymentIntent.clientSdk });
276
281
 
277
282
  // Step 5: Initialize the correct payment provider
278
283
  if (paymentIntent.provider === 'stripe' && stripeProvider) {
@@ -390,8 +395,10 @@ function PaymentIframe({ clientSecret, checkoutId }: { clientSecret: string; che
390
395
  }
391
396
  if (data?.type === 'brainerce:redirect' && typeof data.url === 'string') {
392
397
  // Top-level navigation (e.g. Bit). ALWAYS validate against an allowlist
393
- // before navigating \u2014 never trust the URL blindly.
394
- if (isTrustedPaymentUrl(data.url)) { window.top!.location.href = data.url; }
398
+ // before navigating \u2014 never trust the URL blindly. The SDK ships with
399
+ // a maintained list of payment-provider hosts; prefer it over a local
400
+ // copy so 'npm update brainerce' picks up new providers automatically.
401
+ if (isAllowedPaymentUrl(data.url)) { window.top!.location.href = data.url; }
395
402
  }
396
403
  if (data?.type === 'brainerce:payment-complete') {
397
404
  // Payment done \u2014 redirect to confirmation page which verifies server-side
@@ -431,15 +438,10 @@ function PaymentIframe({ clientSecret, checkoutId }: { clientSecret: string; che
431
438
  );
432
439
  }
433
440
 
434
- // Allowlist of origins you trust for top-level payment redirects.
435
- function isTrustedPaymentUrl(url: string): boolean {
436
- try {
437
- const u = new URL(url);
438
- if (u.protocol !== 'https:') return false;
439
- // Add your provider hostnames here. Example: Cardcom for Bit express-pay.
440
- return u.hostname === 'cardcom.solutions' || u.hostname.endsWith('.cardcom.solutions');
441
- } catch { return false; }
442
- }
441
+ // Allowlist check is provided by the SDK \u2014 covers Stripe, PayPal, Cardcom,
442
+ // Meshulam, Grow, CreditGuard, plus Brainerce-hosted embed shells. To extend
443
+ // for a self-hosted PSP, pass { extraHosts: ['my-psp.example.com'] }.
444
+ import { isAllowedPaymentUrl } from 'brainerce';
443
445
  \`\`\`
444
446
 
445
447
  ### PayPal Payment Form
@@ -727,6 +729,80 @@ Provider-specific install notes:
727
729
  - **Grow:** No SDK needed \u2014 JS SDK loaded via \`clientSdk.scriptUrl\`. Supports credit cards, Bit, Apple Pay, Google Pay.
728
730
  - **Cardcom:** No SDK needed \u2014 rendering driven by \`renderType: 'iframe'\` + the inline/modal branch above. Supports credit cards, Bit (when terminal provisions it), installments, 3D Secure.`;
729
731
  }
732
+ function getSavedPaymentMethodsSection() {
733
+ return `## Saved Payment Methods (vaulted cards)
734
+
735
+ When a logged-in customer ticks "save my card for next time", the platform vaults the
736
+ card with the underlying provider and stores a reference. Vaulted cards can later be
737
+ charged off-session \u2014 typically via subscription / one-click checkout features built
738
+ on top of this foundation.
739
+
740
+ ### Opt-in at checkout
741
+
742
+ Pass \`saveCard: true\` to \`createPaymentIntent\` when the customer chose to save.
743
+ **Only honored for logged-in customers** \u2014 anonymous (guest) checkouts silently
744
+ skip vaulting because there's no Customer record to attach the resulting saved
745
+ method to.
746
+
747
+ \`\`\`typescript
748
+ const intent = await client.createPaymentIntent(checkoutId, {
749
+ successUrl,
750
+ cancelUrl,
751
+ saveCard: true, // optional \u2014 only when customer ticked the box AND is logged in
752
+ });
753
+ \`\`\`
754
+
755
+ After a successful charge, the saved card appears in the customer's profile.
756
+
757
+ ### Listing saved cards (storefront / customer account page)
758
+
759
+ Show the customer their saved cards on a "Manage Payment Methods" page in their
760
+ account. The platform returns display-only metadata \u2014 last4, brand, expiry,
761
+ default flag, status. The underlying provider token is encrypted at rest and
762
+ NEVER returned through the SDK.
763
+
764
+ \`\`\`typescript
765
+ const methods = await client.listSavedPaymentMethods(storeId, customerId);
766
+
767
+ methods.forEach((m) => {
768
+ console.log(\`\${m.brand} ending in \${m.last4} (expires \${m.expMonth}/\${m.expYear})\`);
769
+ if (m.isDefault) console.log(' \u21B3 default');
770
+ if (m.status === 'expired') console.log(' \u21B3 expired \u2014 please add a new card');
771
+ });
772
+ \`\`\`
773
+
774
+ ### Removing a saved card
775
+
776
+ \`\`\`typescript
777
+ await client.removeSavedPaymentMethod(storeId, customerId, methodId);
778
+ \`\`\`
779
+
780
+ Hard-deletes the row from the platform DB. The provider may still hold the
781
+ underlying token internally \u2014 we don't issue a delete-at-provider call because
782
+ not every provider supports it. From the platform's perspective the token is
783
+ gone; subsequent charges fail.
784
+
785
+ ### Provider support matrix
786
+
787
+ | Provider | Save card on first charge | Charge saved card off-session |
788
+ |---|---|---|
789
+ | Cardcom | \u2705 (Operation: ChargeAndCreateToken) | \u2705 |
790
+ | PayPal | \u2705 (Vault API: store_in_vault) | \u2705 |
791
+ | Stripe | (separate workstream \u2014 Brainerce doesn't ship a Stripe payment app yet) | \u2014 |
792
+ | Grow | \u274C \u2014 returns 501 \`token_storage_unavailable\` | \u274C |
793
+
794
+ When a provider doesn't support tokenization, the platform surfaces a 409 with
795
+ \`code: 'token_storage_unavailable'\`. Storefronts should hide the "save card"
796
+ checkbox when the active provider doesn't support it.
797
+
798
+ ### Charging off-session
799
+
800
+ Charging a saved card from the storefront is **not** part of this foundation \u2014
801
+ that's the job of the Subscription / one-click checkout features built on top.
802
+ For now, charges run through the standard \`createPaymentIntent\` flow. The
803
+ saved-method foundation makes those features possible without further platform
804
+ changes.`;
805
+ }
730
806
  function getProductsSection(_currency) {
731
807
  return `## Products & Variants
732
808
 
@@ -747,6 +823,18 @@ const filtered = await client.getProducts({
747
823
  categories: ['cat_123'], minPrice: 10, maxPrice: 100,
748
824
  sortBy: 'price', sortOrder: 'asc',
749
825
  });
826
+
827
+ // Filter by custom fields (metafields). Only fields the merchant marked
828
+ // \`filterable: true\` are honored; supported types are SELECT, MULTI_SELECT,
829
+ // BOOLEAN. AND across keys, OR within a key.
830
+ const byCustom = await client.getProducts({
831
+ metafields: { color: ['red', 'blue'], in_stock: ['true'] },
832
+ });
833
+
834
+ // Discover which custom fields are filterable for the current store:
835
+ const { definitions } = await client.getPublicMetafieldDefinitions();
836
+ const facets = definitions.filter(d => d.filterable);
837
+ // Render a checkbox group per SELECT/MULTI_SELECT, a switch per BOOLEAN.
750
838
  \`\`\`
751
839
 
752
840
  ### i18n \u2014 translated fields come back on every request
@@ -799,6 +887,10 @@ function ProductPage({ product, storeInfo }: { product: Product; storeInfo: Stor
799
887
  ? getVariantPrice(selectedVariant, product.basePrice).toString()
800
888
  : product.salePrice || product.basePrice;
801
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
+
802
894
  // Build attribute buttons (size, color, etc.)
803
895
  const allOptions = product.variants?.map(v => getVariantOptions(v)) || [];
804
896
  const attrNames = [...new Set(allOptions.flatMap(opts => opts.map(o => o.name)))];
@@ -1013,6 +1105,7 @@ const total = cart.total;
1013
1105
 
1014
1106
  ### Coupons
1015
1107
 
1108
+ **On the cart page** (before checkout is created):
1016
1109
  \`\`\`typescript
1017
1110
  // Apply coupon \u2014 returns updated Cart with discountAmount
1018
1111
  const updatedCart = await client.applyCoupon(cartId, 'SAVE20');
@@ -1020,13 +1113,24 @@ console.log(updatedCart.discountAmount); // "10.00"
1020
1113
  console.log(updatedCart.couponCode); // "SAVE20"
1021
1114
 
1022
1115
  // Remove coupon
1023
- const updatedCart = await client.removeCoupon(cartId);
1116
+ await client.removeCoupon(cartId);
1024
1117
 
1025
1118
  // Calculate totals including discount
1026
1119
  const totals = getCartTotals(cart); // { subtotal, discount, shipping, total }
1027
1120
  \`\`\`
1028
1121
 
1029
- 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.
1030
1134
 
1031
1135
  ### \u26A0\uFE0F Checkout Order Summary \u2014 Use checkout.lineItems, NOT cart.items!
1032
1136
 
@@ -1116,22 +1220,24 @@ const { upgrades } = await client.getCartUpgrades(cartId);
1116
1220
  // Show inline banner per cart item if upgrade exists
1117
1221
  \`\`\`
1118
1222
 
1119
- **Bundle offers** (discounted complementary products configured by the store owner):
1223
+ **Bundle offers** (N-product bundles configured by the store owner \u2014 productIds[0] triggers the offer, productIds[1..] are offered together at a discount):
1120
1224
  \`\`\`typescript
1121
1225
  import type { CartBundlesResponse } from 'brainerce';
1122
1226
  const { bundles } = await client.getCartBundles(cartId);
1123
- // bundles[].bundleProduct \u2014 the product to offer
1124
- // bundles[].originalPrice / bundles[].discountedPrice \u2014 prices
1227
+ // bundles[].triggerProductId \u2014 already in cart, activates the offer
1228
+ // bundles[].productIds \u2014 full bundle composition (length >= 2)
1229
+ // bundles[].offeredProducts[] \u2014 products customer hasn't added yet
1230
+ // each with originalPrice + discountedPrice
1231
+ // bundles[].totalOriginalPrice / totalDiscountedPrice \u2014 sums across offeredProducts
1125
1232
  // bundles[].discountType ('PERCENTAGE' | 'FIXED_AMOUNT') + discountValue
1126
- // bundles[].requiresVariantSelection \u2014 true if customer must pick a variant
1127
- // bundles[].lockedVariant \u2014 set if admin pre-selected a specific variant
1128
- // bundles[].bundleProduct.variants \u2014 available variants (only when requiresVariantSelection)
1129
1233
 
1130
- // Add a bundle to cart (applies the discount automatically):
1234
+ // Accept a bundle (adds every offered product not yet in cart at the discount):
1131
1235
  await client.addBundleToCart(cartId, bundleOfferId);
1132
- // For products with variants \u2014 pass the selected variantId:
1133
- await client.addBundleToCart(cartId, bundleOfferId, selectedVariantId);
1134
- // Remove a bundle from cart:
1236
+ // If some offered products have variants, pass per-product variant selections:
1237
+ await client.addBundleToCart(cartId, bundleOfferId, {
1238
+ [variantProductId]: selectedVariantId,
1239
+ });
1240
+ // Remove an accepted bundle (removes every cart item linked to that bundle):
1135
1241
  await client.removeBundleFromCart(cartId, bundleOfferId);
1136
1242
  // Detect already-added bundles: check cart.items for metadata?.isBundleItem === true
1137
1243
  \`\`\`
@@ -1187,11 +1293,12 @@ Always check the flag before rendering: \`if (storeInfo?.upsell?.featureName !==
1187
1293
  | CartRecommendationSection | products/ | Cross-sell grid at bottom of cart page |
1188
1294
  | FreeShippingBar | cart/ | Progress bar toward free shipping threshold |
1189
1295
  | CartUpgradeBanner | cart/ | Inline "Upgrade to X for +$Y" per cart item |
1190
- | CartBundleOfferCard | cart/ | Discounted bundle offer card in cart (with inline variant selector for variable products) |
1296
+ | CartBundleOfferCard | cart/ | N-product bundle offer card in cart \u2014 lists every offered product with its discounted price and an "Add bundle" button |
1191
1297
  | OrderBumpCard | checkout/ | Checkbox add-on card in checkout sidebar (with inline variant selector for variable products) |
1192
1298
 
1193
1299
  ProductRecommendation: \`id\`, \`name\`, \`slug\`, \`basePrice\`, \`salePrice\`, \`images\`, \`type\`, \`inventory\`, \`relationType\`, \`variants?\` (when variant selection needed).
1194
- OrderBump/CartBundleOffer: includes \`requiresVariantSelection\` (boolean) and \`lockedVariant?\` (\`{ id, name, attributes }\`).`;
1300
+ OrderBump: includes \`requiresVariantSelection\` (boolean) and \`lockedVariant?\` (\`{ id, name, attributes }\`).
1301
+ CartBundleOffer: \`productIds\` (length \\>= 2; index 0 = trigger), \`offeredProducts\` (per-product discounted prices), \`totalOriginalPrice\` / \`totalDiscountedPrice\`. Variant selection for offered products is passed per-call via \`variantSelections\` on \`addBundleToCart\`.`;
1195
1302
  }
1196
1303
  function getProductCustomizationFieldsSection() {
1197
1304
  return `## Product Customization Fields (buyer input on product page)
@@ -1297,6 +1404,157 @@ When the order is created, each line's customization values are snapshotted onto
1297
1404
  - Using a raw external URL (not from \`/customization-upload\`) for \`IMAGE\` / \`GALLERY\` \u2192 HTTP 400
1298
1405
  - Assuming \`enumValues\` is present for all types \u2014 only \`SELECT\` / \`MULTI_SELECT\` require it`;
1299
1406
  }
1407
+ function getModifierGroupsSection() {
1408
+ return `## Modifier Groups (toppings, sauce, build-your-own)
1409
+
1410
+ Modifier groups are merchant-defined option blocks attached to a product \u2014 the canonical example is "Toppings" on a pizza, where the customer picks 0\u20138 options with the first 3 free. They differ from \`customizationFields\` (which are arbitrary buyer input \u2014 text, photos, color picks): modifier groups are a **structured selection with priced options**, validated and priced server-side.
1411
+
1412
+ If \`product.modifierGroups\` is empty or missing, render the product page normally and skip everything below.
1413
+
1414
+ ### Wire shape on \`GET /products/:id\`
1415
+
1416
+ \`\`\`typescript
1417
+ import type { Product, ModifierGroup, Modifier, ModifierSelection } from 'brainerce';
1418
+
1419
+ const product = await client.getProductBySlug(slug);
1420
+ const groups: ModifierGroup[] = product.modifierGroups ?? [];
1421
+
1422
+ // Each group looks like:
1423
+ // {
1424
+ // id: 'mg_toppings',
1425
+ // attachmentId: 'pmg_01', // ProductModifierGroup row \u2014 used for attach updates
1426
+ // name: 'Toppings', // customer-facing
1427
+ // internalName?: string, // ADMIN ONLY \u2014 never present in storefront responses
1428
+ // selectionType: 'SINGLE' | 'MULTIPLE',
1429
+ // min: number, // effective (overrides already applied)
1430
+ // max: number | null, // null = unlimited; **0 = group hidden for this variant**
1431
+ // freeQuantity: number, // first N picks at no extra cost
1432
+ // required: boolean,
1433
+ // freeAllocationPolicy: 'EXPENSIVE_FREE' | 'CHEAPEST_FREE' | 'SELECTION_ORDER',
1434
+ // defaultModifierIds: string[], // pre-checked on first render
1435
+ // modifiers: Array<{
1436
+ // id: 'm_olive',
1437
+ // name: 'Olives',
1438
+ // priceDelta: '5.00', // DECIMAL STRING \u2014 never JSON Number
1439
+ // position: 0,
1440
+ // isDefault: false, // pre-check this option even if not in defaultModifierIds
1441
+ // available: true, // false \u2192 "sold out", disabled in UI
1442
+ // referencedProductId?: string, // nested-combo target (depth \u2264 3)
1443
+ // }>,
1444
+ // }
1445
+ \`\`\`
1446
+
1447
+ **Money fields are strings.** \`priceDelta\` is \`"5.00"\` (or \`"-2.00"\` for downsell modifiers \u2014 see below). Use \`parseFloat()\` for display arithmetic; never compute the line total client-side \u2014 the server runs the free-allocation policy and returns the final \`unitPrice\` snapshot on the cart line.
1448
+
1449
+ ### Render: SINGLE \u2192 radio, MULTIPLE \u2192 checkbox
1450
+
1451
+ Walk \`groups\` and pick the input type by \`selectionType\`. Sort modifiers by \`position\`.
1452
+
1453
+ \`\`\`typescript
1454
+ for (const group of groups) {
1455
+ if (group.max === 0) continue; // disabled-for-variant convention \u2014 skip entirely
1456
+ const inputType = group.selectionType === 'SINGLE' ? 'radio' : 'checkbox';
1457
+ const sorted = [...group.modifiers].sort((a, b) => a.position - b.position);
1458
+ // Render fieldset \u2192 legend with name + " *" if required \u2192 list of sorted options.
1459
+ // Each option: input(type=inputType, name=group.id, value=modifier.id),
1460
+ // disabled when modifier.available === false, with a "Sold out" badge.
1461
+ }
1462
+ \`\`\`
1463
+
1464
+ When \`group.freeQuantity > 0\`, show a running counter so the customer understands the rule:
1465
+ \`\`\`
1466
+ {Math.min(picks.length, group.freeQuantity)} of {group.freeQuantity} free
1467
+ \`\`\`
1468
+
1469
+ Note: individual modifiers may have \`excludeFromFree: true\` \u2014 these are "premium" options that always charge their \`priceDelta\` and are never consumed by a free slot. Render them with a label like "premium" so customers know they are not eligible for the free allocation.
1470
+
1471
+ ### Initial state
1472
+
1473
+ Honor \`defaultModifierIds\` first (per-attach defaults set by the merchant for this product/variant), falling back to \`modifier.isDefault\` flags when the group has no per-attach defaults. Filter sold-out modifiers out of the initial picks. For SINGLE groups, cap to one.
1474
+
1475
+ ### Pass selections on add-to-cart
1476
+
1477
+ \`\`\`typescript
1478
+ const selections: ModifierSelection[] = [
1479
+ { modifierGroupId: 'mg_bread', modifierIds: ['m_thick'] },
1480
+ { modifierGroupId: 'mg_toppings', modifierIds: ['m_olive', 'm_mushroom', 'm_bacon', 'm_egg'] },
1481
+ ];
1482
+
1483
+ await client.smartAddToCart({
1484
+ productId: product.id,
1485
+ variantId: selectedVariant?.id,
1486
+ quantity: 1,
1487
+ selections,
1488
+ });
1489
+ \`\`\`
1490
+
1491
+ \`modifierIds\` is in **click-order** \u2014 the server uses this for the \`SELECTION_ORDER\` free-allocation policy. The cart response surfaces a per-line snapshot:
1492
+
1493
+ \`\`\`typescript
1494
+ // cart.items[i].modifiers \u2014 same shape as ModifierSelection but with snapshot data
1495
+ [
1496
+ { modifierId: 'm_olive', name: 'Olives', priceDelta: '5.00', freeApplied: true },
1497
+ { modifierId: 'm_mushroom', name: 'Mushrooms', priceDelta: '5.00', freeApplied: true },
1498
+ { modifierId: 'm_bacon', name: 'Bacon', priceDelta: '7.00', freeApplied: true },
1499
+ { modifierId: 'm_egg', name: 'Egg', priceDelta: '6.00', freeApplied: false },
1500
+ ]
1501
+ // + cart.items[i].modifiersTotal \u2014 sum of paid (non-free) deltas as a decimal string
1502
+ \`\`\`
1503
+
1504
+ \`freeApplied: true\` means the modifier consumed a free slot \u2014 render with a "free" badge in the cart UI.
1505
+
1506
+ ### Editing selections after add-to-cart (idempotent)
1507
+
1508
+ \`PATCH /cart/items/:id\` with a fresh \`selections\` array **replaces** the line's modifiers atomically \u2014 the server deletes old \`CartItemModifier\` rows and recreates them inside the same transaction. Omit \`selections\` from the body to leave them unchanged (e.g., quantity-only update).
1509
+
1510
+ \`\`\`typescript
1511
+ await client.updateCartItem(cart.id, itemId, {
1512
+ quantity: 1,
1513
+ selections: [{ modifierGroupId: 'mg_toppings', modifierIds: ['m_olive', 'm_mushroom'] }],
1514
+ });
1515
+ \`\`\`
1516
+
1517
+ ### Validation envelope
1518
+
1519
+ When the payload is invalid the server returns HTTP 400 with a structured envelope. The SDK exposes it on \`BrainerceError.details\`:
1520
+
1521
+ \`\`\`typescript
1522
+ try {
1523
+ await client.smartAddToCart({ productId, quantity: 1, selections });
1524
+ } catch (err) {
1525
+ const e = err as { statusCode?: number; details?: { code?: string; errors?: Array<{ code: string; message: string; modifierGroupId?: string; modifierId?: string }> } };
1526
+ if (e.statusCode === 400 && e.details?.code === 'MODIFIER_VALIDATION_FAILED') {
1527
+ for (const issue of e.details.errors ?? []) {
1528
+ // issue.code is one of: REQUIRED_GROUP_MISSING | MIN_SELECTIONS_NOT_MET |
1529
+ // MAX_SELECTIONS_EXCEEDED | SINGLE_GROUP_MULTIPLE_PICKS | UNKNOWN_MODIFIER |
1530
+ // UNKNOWN_GROUP | MODIFIER_DISABLED_FOR_VARIANT | MODIFIER_NOT_AVAILABLE |
1531
+ // NESTED_DEPTH_EXCEEDED | NESTED_REQUIRES_PRODUCT_REF | INVALID_PRICE_DELTA |
1532
+ // MODIFIER_PRICE_FLOOR_VIOLATED
1533
+ console.error(issue.message);
1534
+ }
1535
+ }
1536
+ }
1537
+ \`\`\`
1538
+
1539
+ Special case: \`MODIFIER_PRICE_FLOOR_VIOLATED\` fires when downsell modifiers (negative \`priceDelta\`) would push \`unitPrice\` below \`0\`. The server reports a generic message \u2014 internals never leak \u2014 so show a friendly "Cannot apply more discounts on this item" and let the customer remove a downsell.
1540
+
1541
+ ### Disable-for-variant convention (PRD \xA77.2.2)
1542
+
1543
+ The merchant can hide a group entirely for one variant by setting \`maxOverride: 0\` on a per-variant attachment. The product response then returns the group with \`max: 0\` for that variant. **Skip it entirely** \u2014 do not render, do not include in selections. The validator silently skips groups with \`effectiveMax === 0\` on the cart side.
1544
+
1545
+ ### Common mistakes
1546
+
1547
+ - Treating \`priceDelta\` as a number \u2192 arithmetic precision bugs. Always strings; use \`parseFloat\` only at display time.
1548
+ - Computing the line total client-side \u2192 diverges from the server's free-allocation. Render the server's \`unitPrice\` / \`modifiers[]\` / \`modifiersTotal\` instead.
1549
+ - Rendering a group with \`max: 0\` \u2192 it's the variant-disable signal; treat as absent.
1550
+ - Showing \`internalName\` on a public storefront \u2192 it is **never** present in storefront responses; if your code is reading it, you're using an admin endpoint by accident.
1551
+ - Building \`selections\` keyed by \`modifier.name\` \u2192 must be \`modifierGroupId\` + \`modifierIds[]\` (the IDs, not names).
1552
+ - Sending \`modifiers\` instead of \`selections\` on add-to-cart \u2192 \`modifiers\` is the response field; the request key is \`selections\`.
1553
+
1554
+ ### Restaurant features (advanced)
1555
+
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.`;
1557
+ }
1300
1558
  function getInventorySection() {
1301
1559
  return `## Inventory, Stock Display & Reservation Countdown
1302
1560
 
@@ -1692,7 +1950,7 @@ subsequent SDK call sends the \`Accept-Language\` header automatically:
1692
1950
  \`\`\`typescript
1693
1951
  import { BrainerceClient } from 'brainerce';
1694
1952
 
1695
- const client = new BrainerceClient({ connectionId: 'vc_...' });
1953
+ const client = new BrainerceClient({ salesChannelId: 'vc_...' });
1696
1954
  client.setLocale('he'); // done \u2014 all calls are now in Hebrew
1697
1955
  \`\`\`
1698
1956
 
@@ -1862,7 +2120,40 @@ const admin = new BrainerceClient({ apiKey: 'brainerce_...' });
1862
2120
  // Email: getEmailTemplates(), createEmailTemplate()
1863
2121
  // Conflicts: getSyncConflicts(), resolveSyncConflict()
1864
2122
  // OAuth: getOAuthProviders(), configureOAuthProvider()
1865
- \`\`\``;
2123
+ \`\`\`
2124
+
2125
+ ### Per-Channel Publishing
2126
+
2127
+ Categories, tags, brands, and metafield definitions are gated to specific
2128
+ vibe-coded sites \u2014 same control already exposed for products and coupons.
2129
+ **Explicit opt-in:** an entity is visible to a vibe-coded site only if it
2130
+ has been explicitly published to that connection. Entities with no publish
2131
+ rows are invisible to every site (the merchant must publish them via the
2132
+ dashboard or the admin SDK).
2133
+
2134
+ \`\`\`typescript
2135
+ // Publish to a specific vibe-coded site (admin mode):
2136
+ await admin.publishCategoryToVibeCodedSite('cat_id', 'conn_id');
2137
+ await admin.publishTagToVibeCodedSite('tag_id', 'conn_id');
2138
+ await admin.publishBrandToVibeCodedSite('brand_id', 'conn_id');
2139
+ await admin.publishMetafieldDefinitionToVibeCodedSite('def_id', 'conn_id');
2140
+
2141
+ // Unpublish (entity stays visible to other sites unless they also have publishes):
2142
+ await admin.unpublishCategoryFromVibeCodedSite('cat_id', 'conn_id');
2143
+ // ...same for tag/brand/metafield definition.
2144
+
2145
+ // Read which sites an entity is published to via list/get responses:
2146
+ const cat = await admin.getCategory('cat_id');
2147
+ cat.vibeCodedPublishes; // [{ connection: { id, name, connectionId } }, ...]
2148
+ \`\`\`
2149
+
2150
+ Cross-account isolation is enforced server-side: a publish call only
2151
+ succeeds when entity and connection belong to the same account. Cross-account
2152
+ calls fail with \`404 Not Found\`.
2153
+
2154
+ The vibe-coded read endpoints (used by storefront SDK calls in \`connectionId\`
2155
+ mode) automatically filter by these junction tables, so storefronts never see
2156
+ entities that weren't published to their site.`;
1866
2157
  }
1867
2158
  function getContactInquiriesSection() {
1868
2159
  return `## Contact Inquiries & Forms (optional)
@@ -1937,7 +2228,7 @@ const form = await brainerce.contactForms.get('main', 'en');
1937
2228
  // \u2192 {
1938
2229
  // id, key, name, description?, submitButton, successMessage,
1939
2230
  // fields: [{ key, type, label, placeholder?, helpText?, isRequired,
1940
- // enumValues?, validation?, defaultValue? }, ...]
2231
+ // enumValues?, validation?, defaultValue?, width? }, ...]
1941
2232
  // }
1942
2233
 
1943
2234
  // Submit a keyed payload. Unknown keys are stripped, required fields are
@@ -1963,6 +2254,20 @@ stripped server-side.
1963
2254
  Render every field type the merchant can pick. Keep this as a \`<DynamicField>\`
1964
2255
  component so the form is fully driven by \`schema.fields\`.
1965
2256
 
2257
+ **Layout.** Each field carries an optional \`width\` hint:
2258
+
2259
+ | \`field.width\` | Meaning | Grid style (6-column grid) |
2260
+ | ------------- | ------- | -------------------------- |
2261
+ | \`'FULL'\` (default) | Full row | \`grid-column: span 6\` |
2262
+ | \`'HALF'\` | Half row | \`grid-column: span 3\` |
2263
+ | \`'THIRD'\` | One-third row | \`grid-column: span 2\` |
2264
+
2265
+ Stack fields to full width on small screens (< ~640 px).
2266
+
2267
+ **Required fields.** Show a red asterisk on required labels, validate
2268
+ **client-side** before calling \`createInquiry()\`, and show inline error
2269
+ messages. Do NOT rely only on the server returning 400.
2270
+
1966
2271
  \`\`\`tsx
1967
2272
  import type { ContactFormPublic, ContactFormPublicField } from 'brainerce';
1968
2273
 
@@ -1980,13 +2285,24 @@ function isEmpty(v: FieldValue): boolean {
1980
2285
  return v === false;
1981
2286
  }
1982
2287
 
2288
+ // Map width \u2192 CSS grid column span (6-column grid)
2289
+ function widthClass(w?: string): string {
2290
+ switch (w) {
2291
+ case 'HALF': return 'grid-col-half'; // grid-column: span 3; on sm+, full on mobile
2292
+ case 'THIRD': return 'grid-col-third'; // grid-column: span 2; on sm+, full on mobile
2293
+ default: return 'grid-col-full'; // grid-column: span 6
2294
+ }
2295
+ }
2296
+
1983
2297
  function DynamicField({
1984
2298
  field,
1985
2299
  value,
2300
+ error,
1986
2301
  onChange,
1987
2302
  }: {
1988
2303
  field: ContactFormPublicField;
1989
2304
  value: FieldValue;
2305
+ error?: string;
1990
2306
  onChange: (v: FieldValue) => void;
1991
2307
  }) {
1992
2308
  const id = \`contact-\${field.key}\`;
@@ -2001,37 +2317,40 @@ function DynamicField({
2001
2317
  </label>
2002
2318
  );
2003
2319
  const help = field.helpText ? <p className="mt-1 text-xs opacity-70">{field.helpText}</p> : null;
2320
+ const errorEl = error ? <p className="mt-1 text-xs text-red-500">{error}</p> : null;
2004
2321
 
2322
+ // Outer div uses widthClass to control grid-column span
2005
2323
  switch (field.type) {
2006
2324
  case 'TEXTAREA':
2007
- return (<div>{label}<textarea id={id} required={field.isRequired} maxLength={maxLength} minLength={minLength} rows={6} placeholder={field.placeholder} value={strVal} onChange={(e) => onChange(e.target.value)} />{help}</div>);
2325
+ return (<div className={widthClass(field.width)}>{label}<textarea id={id} required={field.isRequired} maxLength={maxLength} minLength={minLength} rows={6} placeholder={field.placeholder} value={strVal} onChange={(e) => onChange(e.target.value)} />{help}{errorEl}</div>);
2008
2326
  case 'EMAIL':
2009
- return (<div>{label}<input id={id} type="email" required={field.isRequired} autoComplete="email" placeholder={field.placeholder} value={strVal} onChange={(e) => onChange(e.target.value)} />{help}</div>);
2327
+ return (<div className={widthClass(field.width)}>{label}<input id={id} type="email" required={field.isRequired} autoComplete="email" placeholder={field.placeholder} value={strVal} onChange={(e) => onChange(e.target.value)} />{help}{errorEl}</div>);
2010
2328
  case 'PHONE':
2011
- return (<div>{label}<input id={id} type="tel" required={field.isRequired} autoComplete="tel" placeholder={field.placeholder} value={strVal} onChange={(e) => onChange(e.target.value)} />{help}</div>);
2329
+ return (<div className={widthClass(field.width)}>{label}<input id={id} type="tel" required={field.isRequired} autoComplete="tel" placeholder={field.placeholder} value={strVal} onChange={(e) => onChange(e.target.value)} />{help}{errorEl}</div>);
2012
2330
  case 'URL':
2013
- return (<div>{label}<input id={id} type="url" required={field.isRequired} placeholder={field.placeholder} value={strVal} onChange={(e) => onChange(e.target.value)} />{help}</div>);
2331
+ return (<div className={widthClass(field.width)}>{label}<input id={id} type="url" required={field.isRequired} placeholder={field.placeholder} value={strVal} onChange={(e) => onChange(e.target.value)} />{help}{errorEl}</div>);
2014
2332
  case 'NUMBER':
2015
- return (<div>{label}<input id={id} type="number" required={field.isRequired} min={min} max={max} placeholder={field.placeholder} value={strVal} onChange={(e) => onChange(e.target.value)} />{help}</div>);
2333
+ return (<div className={widthClass(field.width)}>{label}<input id={id} type="number" required={field.isRequired} min={min} max={max} placeholder={field.placeholder} value={strVal} onChange={(e) => onChange(e.target.value)} />{help}{errorEl}</div>);
2016
2334
  case 'DATE':
2017
- return (<div>{label}<input id={id} type="date" required={field.isRequired} value={strVal} onChange={(e) => onChange(e.target.value)} />{help}</div>);
2335
+ return (<div className={widthClass(field.width)}>{label}<input id={id} type="date" required={field.isRequired} value={strVal} onChange={(e) => onChange(e.target.value)} />{help}{errorEl}</div>);
2018
2336
  case 'SELECT':
2019
- return (<div>{label}<select id={id} required={field.isRequired} value={strVal} onChange={(e) => onChange(e.target.value)}><option value="">\u2014</option>{field.enumValues?.map((o) => (<option key={o.value} value={o.value}>{o.label}</option>))}</select>{help}</div>);
2337
+ return (<div className={widthClass(field.width)}>{label}<select id={id} required={field.isRequired} value={strVal} onChange={(e) => onChange(e.target.value)}><option value="">\u2014</option>{field.enumValues?.map((o) => (<option key={o.value} value={o.value}>{o.label}</option>))}</select>{help}{errorEl}</div>);
2020
2338
  case 'MULTI_SELECT': {
2021
2339
  const arr = Array.isArray(value) ? value : [];
2022
- return (<div>{label}<div>{field.enumValues?.map((o) => { const checked = arr.includes(o.value); return (<label key={o.value}><input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked ? [...arr, o.value] : arr.filter((v) => v !== o.value))} /><span>{o.label}</span></label>); })}</div>{help}</div>);
2340
+ return (<div className={widthClass(field.width)}>{label}<div>{field.enumValues?.map((o) => { const checked = arr.includes(o.value); return (<label key={o.value}><input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked ? [...arr, o.value] : arr.filter((v) => v !== o.value))} /><span>{o.label}</span></label>); })}</div>{help}{errorEl}</div>);
2023
2341
  }
2024
2342
  case 'CHECKBOX':
2025
- return (<div><label htmlFor={id}><input id={id} type="checkbox" required={field.isRequired} checked={value === true} onChange={(e) => onChange(e.target.checked)} /><span>{field.label}</span></label>{help}</div>);
2343
+ return (<div className={widthClass(field.width)}><label htmlFor={id}><input id={id} type="checkbox" required={field.isRequired} checked={value === true} onChange={(e) => onChange(e.target.checked)} /><span>{field.label}</span></label>{help}{errorEl}</div>);
2026
2344
  case 'TEXT':
2027
2345
  default:
2028
- return (<div>{label}<input id={id} type="text" required={field.isRequired} maxLength={maxLength} minLength={minLength} pattern={pattern} placeholder={field.placeholder} value={strVal} onChange={(e) => onChange(e.target.value)} />{help}</div>);
2346
+ return (<div className={widthClass(field.width)}>{label}<input id={id} type="text" required={field.isRequired} maxLength={maxLength} minLength={minLength} pattern={pattern} placeholder={field.placeholder} value={strVal} onChange={(e) => onChange(e.target.value)} />{help}{errorEl}</div>);
2029
2347
  }
2030
2348
  }
2031
2349
 
2032
2350
  export function ContactPage() {
2033
2351
  const [schema, setSchema] = useState<ContactFormPublic | null>(null);
2034
2352
  const [values, setValues] = useState<Record<string, FieldValue>>({});
2353
+ const [errors, setErrors] = useState<Record<string, string>>({});
2035
2354
  const [honeypot, setHoneypot] = useState('');
2036
2355
  const [sent, setSent] = useState(false);
2037
2356
  const [loading, setLoading] = useState(false);
@@ -2055,6 +2374,19 @@ export function ContactPage() {
2055
2374
  if (loading) return;
2056
2375
  if (honeypot.trim().length > 0) { setSent(true); return; } // bot \u2192 silent success
2057
2376
 
2377
+ // \u2500\u2500 Client-side required-field validation \u2500\u2500
2378
+ const newErrors: Record<string, string> = {};
2379
+ for (const f of schema.fields) {
2380
+ if (f.isRequired && isEmpty(values[f.key] ?? defaultValueFor(f))) {
2381
+ newErrors[f.key] = \`\${f.label} is required\`; // or pull from your i18n system
2382
+ }
2383
+ }
2384
+ if (Object.keys(newErrors).length > 0) {
2385
+ setErrors(newErrors);
2386
+ return; // block submission
2387
+ }
2388
+ setErrors({});
2389
+
2058
2390
  setLoading(true);
2059
2391
  try {
2060
2392
  const payload: Record<string, unknown> = {};
@@ -2087,11 +2419,15 @@ export function ContactPage() {
2087
2419
  value={honeypot} onChange={(e) => setHoneypot(e.target.value)} />
2088
2420
  </div>
2089
2421
 
2090
- {schema.fields.map((field) => (
2091
- <DynamicField key={field.key} field={field}
2092
- value={values[field.key] ?? defaultValueFor(field)}
2093
- onChange={(v) => setValues((p) => ({ ...p, [field.key]: v }))} />
2094
- ))}
2422
+ {/* CSS grid \u2014 6 columns on sm+, 1 column on mobile */}
2423
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(6, 1fr)', gap: '1rem' }}>
2424
+ {schema.fields.map((field) => (
2425
+ <DynamicField key={field.key} field={field}
2426
+ value={values[field.key] ?? defaultValueFor(field)}
2427
+ error={errors[field.key]}
2428
+ onChange={(v) => setValues((p) => ({ ...p, [field.key]: v }))} />
2429
+ ))}
2430
+ </div>
2095
2431
 
2096
2432
  <button type="submit" disabled={loading}>
2097
2433
  {loading ? '\u2026' : schema.submitButton}
@@ -2101,13 +2437,27 @@ export function ContactPage() {
2101
2437
  }
2102
2438
  \`\`\`
2103
2439
 
2440
+ CSS for the grid column helpers (or use the equivalent Tailwind / inline styles):
2441
+
2442
+ \`\`\`css
2443
+ .grid-col-full { grid-column: span 6; }
2444
+ .grid-col-half { grid-column: span 6; }
2445
+ .grid-col-third { grid-column: span 6; }
2446
+
2447
+ @media (min-width: 640px) {
2448
+ .grid-col-half { grid-column: span 3; }
2449
+ .grid-col-third { grid-column: span 2; }
2450
+ }
2451
+ \`\`\`
2452
+
2104
2453
  ### Rules
2105
2454
 
2106
2455
  - **Rate limit:** 3 submissions / 60s per IP \u2014 show a friendly "try again later" message on HTTP 429.
2107
2456
  - **Honeypot:** always render a hidden field named \`honeypot\` and **never send** it. Bots fill every input; the server rejects submissions carrying a non-empty \`honeypot\`.
2108
2457
  - **Built-in keys:** \`name\`, \`email\`, \`phone\`, \`subject\`, \`message\` always exist on the default form; the legacy \`createInquiry\` shape keeps working forever.
2109
2458
  - **Max values:** each field value is capped at 10 000 chars server-side. Validate client-side before submitting using \`field.validation.maxLength\`.
2110
- - **Required fields:** respect \`field.isRequired\`. The server validates too, but show \`required\` on the input for browser-level UX.
2459
+ - **Required fields:** respect \`field.isRequired\`. Show a red asterisk (\`*\`) next to the label. **Validate client-side before submission** \u2014 iterate over \`schema.fields\`, check \`isEmpty(values[f.key])\` for each required field, and block the submit with inline error messages. The server validates too, but the user should never see a raw 400 error.
2460
+ - **Width (layout):** respect \`field.width\`. Use a CSS grid with 6 columns: \`FULL\` = span 6 (default), \`HALF\` = span 3, \`THIRD\` = span 2. Stack to full width on small screens. If \`width\` is missing, treat as \`FULL\`.
2111
2461
  - **Validation:** when \`field.validation.pattern\` is present, pass it as the input's \`pattern\` attribute; the server also enforces it. Likewise \`minLength\`/\`maxLength\`/\`min\`/\`max\`.
2112
2462
  - **Enum values:** SELECT and MULTI_SELECT always return \`enumValues\` (non-empty array). Render from \`enumValues\`, never a hardcoded list.
2113
2463
  - **Visibility:** the server strips fields with \`isVisible=false\`, so \`schema.fields\` only contains things to render.
@@ -2139,6 +2489,8 @@ function getSectionByTopic(topic, connectionId, currency) {
2139
2489
  return getCheckoutCustomFieldsSection();
2140
2490
  case "payment":
2141
2491
  return getPaymentProvidersSection();
2492
+ case "saved-payment-methods":
2493
+ return getSavedPaymentMethodsSection();
2142
2494
  case "auth":
2143
2495
  return getCustomerAuthSection();
2144
2496
  case "order-confirmation":
@@ -2151,6 +2503,8 @@ function getSectionByTopic(topic, connectionId, currency) {
2151
2503
  return getRecommendationsSection();
2152
2504
  case "product-customization-fields":
2153
2505
  return getProductCustomizationFieldsSection();
2506
+ case "modifier-groups":
2507
+ return getModifierGroupsSection();
2154
2508
  case "tax":
2155
2509
  return getTaxDisplaySection(cur);
2156
2510
  case "i18n":
@@ -2231,6 +2585,10 @@ function getSectionByTopic(topic, connectionId, currency) {
2231
2585
  "",
2232
2586
  "---",
2233
2587
  "",
2588
+ getModifierGroupsSection(),
2589
+ "",
2590
+ "---",
2591
+ "",
2234
2592
  getTaxDisplaySection(cur),
2235
2593
  "",
2236
2594
  "---",
@@ -2275,11 +2633,19 @@ var GET_SDK_DOCS_SCHEMA = {
2275
2633
  "inquiries",
2276
2634
  "all"
2277
2635
  ]).describe("The SDK documentation topic to retrieve"),
2278
- connectionId: import_zod.z.string().optional().describe("Vibe-coded connection ID (starts with vc_). Used to personalize setup code."),
2636
+ salesChannelId: import_zod.z.string().optional().describe("Sales channel ID (starts with vc_). Used to personalize setup code."),
2637
+ /** @deprecated alias of salesChannelId */
2638
+ connectionId: import_zod.z.string().optional().describe("Deprecated alias of salesChannelId \u2014 kept for backwards compat"),
2279
2639
  currency: import_zod.z.string().optional().describe("Store currency code (e.g., USD, ILS, EUR). Used in price formatting examples.")
2280
2640
  };
2281
2641
  async function handleGetSdkDocs(args) {
2282
- const content = getSectionByTopic(args.topic, args.connectionId, args.currency);
2642
+ const id = args.salesChannelId ?? args.connectionId;
2643
+ if (!args.salesChannelId && args.connectionId) {
2644
+ console.warn(
2645
+ "get-sdk-docs: `connectionId` is deprecated \u2014 use `salesChannelId` instead"
2646
+ );
2647
+ }
2648
+ const content = getSectionByTopic(args.topic, id, args.currency);
2283
2649
  return {
2284
2650
  content: [{ type: "text", text: content }]
2285
2651
  };
@@ -2301,6 +2667,9 @@ interface Product {
2301
2667
  basePrice: string; // Use parseFloat() for calculations
2302
2668
  salePrice?: string | null;
2303
2669
  costPrice?: string | null;
2670
+ priceMin?: string | null; // Lowest variant price (VARIABLE products). Use for catalog/JSON-LD range display.
2671
+ priceMax?: string | null; // Highest variant price (VARIABLE products).
2672
+ priceVaries?: boolean; // true when variant prices differ \u2014 show "\u20AA49 \u2013 \u20AA199" range.
2304
2673
  status: string;
2305
2674
  type: 'SIMPLE' | 'VARIABLE';
2306
2675
  isDownloadable?: boolean;
@@ -2318,7 +2687,7 @@ interface Product {
2318
2687
  attributeId: string;
2319
2688
  attributeOptionId: string;
2320
2689
  platform: string;
2321
- attribute: { id: string; name: string; displayType?: 'DEFAULT' | 'COLOR_SWATCH' | 'IMAGE_SWATCH' } | null;
2690
+ attribute: { id: string; name: string; displayType?: 'DEFAULT' | 'COLOR_SWATCH' | 'IMAGE_SWATCH' | 'MIXED_SWATCH' } | null;
2322
2691
  attributeOption: { id: string; name: string; value?: string | null; swatchColor?: string | null; swatchColor2?: string | null; swatchImageUrl?: string | null } | null;
2323
2692
  }>;
2324
2693
  createdAt: string;
@@ -2327,6 +2696,8 @@ interface Product {
2327
2696
 
2328
2697
  // Use getProductSwatches(product) to get grouped swatch data for storefront rendering.
2329
2698
  // Returns Array<{ attributeName, displayType, options: Array<{ name, swatchColor?, swatchColor2?, swatchImageUrl? }> }>
2699
+ // displayType rendering: COLOR_SWATCH \u2192 color circle (swatchColor/swatchColor2); IMAGE_SWATCH \u2192 image thumbnail (swatchImageUrl);
2700
+ // MIXED_SWATCH \u2192 per-option: render swatchImageUrl if set, else swatchColor if set, else text only.
2330
2701
 
2331
2702
  interface ProductImage {
2332
2703
  url: string;
@@ -2407,6 +2778,10 @@ interface ProductQueryParams {
2407
2778
  tags?: string | string[];
2408
2779
  minPrice?: number;
2409
2780
  maxPrice?: number;
2781
+ // Filter by custom-field (metafield) values. Keys = definition.key,
2782
+ // values = accepted values. Only definitions with filterable=true and
2783
+ // type SELECT/MULTI_SELECT/BOOLEAN are honored. AND across keys, OR within.
2784
+ metafields?: Record<string, string | string[]>;
2410
2785
  sortBy?: 'name' | 'price' | 'createdAt';
2411
2786
  sortOrder?: 'asc' | 'desc';
2412
2787
  }
@@ -2824,6 +3199,36 @@ interface PaymentStatus {
2824
3199
  error?: string;
2825
3200
  }
2826
3201
 
3202
+ // ---- Saved Payment Methods (vaulted cards) ----
3203
+ // Display-only summary of a customer's vaulted payment method. The
3204
+ // underlying provider token is encrypted at rest in the platform DB
3205
+ // and NEVER returned through the SDK.
3206
+ //
3207
+ // To opt into vaulting at checkout, pass saveCard: true to
3208
+ // createPaymentIntent (only honored for logged-in customers).
3209
+ //
3210
+ // To list / remove saved methods, see:
3211
+ // client.listSavedPaymentMethods(storeId, customerId)
3212
+ // client.removeSavedPaymentMethod(storeId, customerId, methodId)
3213
+
3214
+ interface SavedPaymentMethodSummary {
3215
+ id: string;
3216
+ customerId: string;
3217
+ appInstallationId: string;
3218
+ paymentMethod: string; // 'credit_card' | 'paypal' | 'bank_account'
3219
+ brand: string | null;
3220
+ last4: string | null;
3221
+ expMonth: number | null;
3222
+ expYear: number | null;
3223
+ isDefault: boolean;
3224
+ status: string; // 'active' | 'expired' | 'invalid'
3225
+ failureReason: string | null;
3226
+ lastUsedAt: string | null;
3227
+ expiresAt: string | null;
3228
+ createdAt: string;
3229
+ updatedAt: string;
3230
+ }
3231
+
2827
3232
  interface WaitForOrderResult {
2828
3233
  success: boolean;
2829
3234
  status: PaymentStatus; // Access: result.status.orderNumber
@@ -2835,9 +3240,9 @@ var HELPERS_TYPES = `// ---- Helper Functions (import from 'brainerce') ----
2835
3240
  // Price helpers
2836
3241
  function formatPrice(priceString: string | number | undefined | null, options?: { currency?: string; locale?: string; }): string;
2837
3242
  function getProductPrice(product: Pick<Product, 'basePrice' | 'salePrice'>): number;
2838
- function getProductPriceInfo(product: Pick<Product, 'basePrice' | 'salePrice'>): {
3243
+ function getProductPriceInfo(product: Pick<Product, 'basePrice' | 'salePrice' | 'discount' | 'priceMin' | 'priceVaries'>): {
2839
3244
  price: number; originalPrice: number; isOnSale: boolean; discountAmount: number; discountPercent: number;
2840
- };
3245
+ }; // Falls back to priceMin when basePrice=0 (VARIABLE products)
2841
3246
  function getVariantPrice(variant: Pick<ProductVariant, 'price' | 'salePrice'>, productBasePrice: string): number;
2842
3247
 
2843
3248
  // Cart helpers
@@ -2923,15 +3328,32 @@ interface CartUpgradeSuggestion {
2923
3328
  priceDelta: string;
2924
3329
  deltaPercent: number;
2925
3330
  }
2926
- interface CartBundleOffer {
3331
+ interface CartBundleOfferOfferedProduct {
2927
3332
  id: string;
2928
- bundleProduct: ProductRecommendation;
3333
+ name: string;
3334
+ slug: string | null;
3335
+ basePrice: string;
3336
+ salePrice: string | null;
3337
+ images: Array<{ url: string }>;
3338
+ type: string;
2929
3339
  originalPrice: string;
2930
3340
  discountedPrice: string;
3341
+ }
3342
+ interface CartBundleOffer {
3343
+ id: string;
3344
+ name: string;
3345
+ description: string | null;
3346
+ // productIds[0] = trigger product (must be in cart for the bundle to surface);
3347
+ // productIds[1..] = offered together at the bundle discount.
3348
+ triggerProductId: string;
3349
+ productIds: string[];
3350
+ // offeredProducts = productIds[1..] minus those already in cart, each with
3351
+ // its own original/discounted price applied.
3352
+ offeredProducts: CartBundleOfferOfferedProduct[];
2931
3353
  discountType: 'PERCENTAGE' | 'FIXED_AMOUNT';
2932
- discountValue: number;
2933
- requiresVariantSelection: boolean;
2934
- lockedVariant?: { id: string; name?: string };
3354
+ discountValue: string;
3355
+ totalOriginalPrice: string;
3356
+ totalDiscountedPrice: string;
2935
3357
  }`;
2936
3358
  var INQUIRIES_TYPES = `// ---- Contact Inquiries & Forms ----
2937
3359
 
@@ -2986,6 +3408,7 @@ interface ContactFormPublicField {
2986
3408
  enumValues?: { value: string; label: string }[]; // present (non-empty) for SELECT / MULTI_SELECT
2987
3409
  validation?: ContactFormFieldValidation;
2988
3410
  defaultValue?: string;
3411
+ width?: 'FULL' | 'HALF' | 'THIRD'; // layout hint \u2014 FULL = full row, HALF = half row, THIRD = one-third row
2989
3412
  }
2990
3413
 
2991
3414
  interface ContactFormPublic {
@@ -3017,6 +3440,19 @@ interface ContactFormSummary {
3017
3440
  // MULTI_SELECT \u2192 multiple <input type="checkbox">, value is string[]
3018
3441
  // CHECKBOX \u2192 single <input type="checkbox">, value is boolean
3019
3442
  // ------------------------------------------------------------------
3443
+ //
3444
+ // Layout: render fields inside a CSS grid container.
3445
+ // width='FULL' (default) \u2192 span entire row
3446
+ // width='HALF' \u2192 span half the row (two HALF fields sit side-by-side)
3447
+ // width='THIRD' \u2192 span one-third of the row (three THIRD fields sit side-by-side)
3448
+ // Use a 6-column grid for clean divisibility:
3449
+ // FULL \u2192 col-span-6 | HALF \u2192 col-span-3 | THIRD \u2192 col-span-2
3450
+ // Stack to full width on small screens (< sm breakpoint).
3451
+ //
3452
+ // Required fields: show a red asterisk (*) next to the label, validate
3453
+ // client-side before submission, and display inline error messages for
3454
+ // any empty required field. Do NOT rely only on the server returning 400.
3455
+ // ------------------------------------------------------------------
3020
3456
 
3021
3457
  // SDK methods
3022
3458
  // await brainerce.createInquiry(input) \u2192 POST /stores/{storeId}/inquiries
@@ -3029,6 +3465,130 @@ interface ContactFormSummary {
3029
3465
  // - Always pass \`locale\` \u2014 inbox filters inquiries by language, and schema labels come back translated
3030
3466
  // - Render \`schema.name\` / \`description\` / \`submitButton\` / \`successMessage\` directly \u2014 do NOT hardcode copy
3031
3467
  // - Unknown field keys are stripped server-side \u2014 safe to send extras during dev`;
3468
+ var MODIFIER_GROUPS_TYPES = `// ---- Modifier Groups (Restaurant / Build-Your-Own) ----
3469
+ // Modifier groups are merchant-defined option blocks attached to a product
3470
+ // (toppings, sauce, bread type, \u2026). They differ from product.customizationFields:
3471
+ // modifier groups are STRUCTURED priced choices validated server-side, while
3472
+ // customizationFields are arbitrary buyer input (text/photo/color).
3473
+ //
3474
+ // Money fields are decimal STRINGS on the wire \u2014 "5.00", "-2.00" (downsell).
3475
+ // Never JSON Number. Use parseFloat() only at display time. Do not compute
3476
+ // the line total client-side; the server runs free-allocation and returns
3477
+ // cart.items[i].unitPrice + .modifiers[] + .modifiersTotal.
3478
+
3479
+ export type ModifierSelectionType = 'SINGLE' | 'MULTIPLE';
3480
+
3481
+ export type FreeAllocationPolicy = 'EXPENSIVE_FREE' | 'CHEAPEST_FREE' | 'SELECTION_ORDER';
3482
+
3483
+ export interface Modifier {
3484
+ id: string;
3485
+ name: string;
3486
+ description?: string;
3487
+ /** Decimal string. Negative values = downsell modifiers ("-2.00"). */
3488
+ priceDelta: string;
3489
+ sku?: string;
3490
+ image?: { url: string; thumbnailUrl?: string; alt?: string };
3491
+ position: number;
3492
+ /** Pre-checked on first render. */
3493
+ isDefault: boolean;
3494
+ /** false = sold out \u2014 disable in UI with a "Sold out" badge. */
3495
+ available: boolean;
3496
+ /** When true, never applied as a free selection \u2014 always charges priceDelta even when freeQuantity > 0 on the group. */
3497
+ excludeFromFree?: boolean;
3498
+ /** Nested combo: opens a sub-flow; depth \u2264 3 enforced server-side. */
3499
+ referencedProductId?: string;
3500
+ translations?: Record<string, { name?: string; description?: string }>;
3501
+ }
3502
+
3503
+ export interface ModifierGroup {
3504
+ id: string;
3505
+ /**
3506
+ * Set when fetched in a product context (the ProductModifierGroup row id).
3507
+ * Use this to update or detach the attachment without reattaching the group.
3508
+ */
3509
+ attachmentId?: string;
3510
+ /** Customer-facing canonical name. */
3511
+ name: string;
3512
+ /**
3513
+ * Admin-only disambiguator \u2014 NEVER present in storefront responses.
3514
+ * If your client code reads this, you're hitting an admin endpoint by mistake.
3515
+ */
3516
+ internalName?: string;
3517
+ description?: string;
3518
+ selectionType: ModifierSelectionType;
3519
+ /** Effective minimum after any per-attach / per-variant overrides. */
3520
+ min: number;
3521
+ /** Effective maximum; null = unlimited. **0 = group hidden for this variant** (PRD \xA77.2.2). */
3522
+ max?: number | null;
3523
+ freeQuantity: number;
3524
+ required: boolean;
3525
+ freeAllocationPolicy: FreeAllocationPolicy;
3526
+ modifiers: Modifier[];
3527
+ /** Effective default selections (after attachment-level overrides). */
3528
+ defaultModifierIds: string[];
3529
+ translations?: Record<string, { name?: string; description?: string }>;
3530
+ }
3531
+
3532
+ /** Customer-side selection payload \u2014 modifierIds in click-order. */
3533
+ export interface ModifierSelection {
3534
+ modifierGroupId: string;
3535
+ modifierIds: string[];
3536
+ }
3537
+
3538
+ /** Per-line modifier breakdown surfaced on cart.items[i] / order.items[i]. */
3539
+ export interface CartItemModifierLine {
3540
+ modifierId: string;
3541
+ /** Snapshot of the modifier's name at the time the line was added. */
3542
+ name: string;
3543
+ /** Decimal string snapshot of the priceDelta at the time the line was added. */
3544
+ priceDelta: string;
3545
+ /** True if this modifier consumed one of the group's free slots. */
3546
+ freeApplied: boolean;
3547
+ }
3548
+
3549
+ /**
3550
+ * Stable error codes returned in the structured 400 envelope when a cart
3551
+ * payload fails server-side validation. The SDK exposes the envelope on
3552
+ * BrainerceError.details \u2014 switch on details.code === 'MODIFIER_VALIDATION_FAILED'
3553
+ * first, then iterate details.errors[].
3554
+ */
3555
+ export type ModifierValidationCode =
3556
+ | 'REQUIRED_GROUP_MISSING'
3557
+ | 'MIN_SELECTIONS_NOT_MET'
3558
+ | 'MAX_SELECTIONS_EXCEEDED'
3559
+ | 'SINGLE_GROUP_MULTIPLE_PICKS'
3560
+ | 'UNKNOWN_MODIFIER'
3561
+ | 'UNKNOWN_GROUP'
3562
+ | 'MODIFIER_DISABLED_FOR_VARIANT'
3563
+ | 'MODIFIER_NOT_AVAILABLE'
3564
+ | 'NESTED_DEPTH_EXCEEDED'
3565
+ | 'NESTED_REQUIRES_PRODUCT_REF'
3566
+ | 'INVALID_PRICE_DELTA'
3567
+ | 'MODIFIER_PRICE_FLOOR_VIOLATED';
3568
+
3569
+ export interface ModifierValidationError {
3570
+ code: ModifierValidationCode;
3571
+ message: string;
3572
+ modifierGroupId?: string;
3573
+ modifierId?: string;
3574
+ }
3575
+
3576
+ // Cart DTOs gain optional selections + nestedByModifierId \u2014 see CART_TYPES above.
3577
+ // Add to cart with selections:
3578
+ //
3579
+ // await client.smartAddToCart({
3580
+ // productId,
3581
+ // variantId,
3582
+ // quantity: 1,
3583
+ // selections: [
3584
+ // { modifierGroupId: 'mg_bread', modifierIds: ['m_thick'] },
3585
+ // { modifierGroupId: 'mg_toppings', modifierIds: ['m_olive', 'm_bacon'] },
3586
+ // ],
3587
+ // });
3588
+ //
3589
+ // The cart line then carries:
3590
+ // cart.items[i].modifiers \u2014 CartItemModifierLine[]
3591
+ // cart.items[i].modifiersTotal \u2014 decimal string of the paid (non-free) deltas`;
3032
3592
  var TYPES_BY_DOMAIN = {
3033
3593
  products: PRODUCTS_TYPES,
3034
3594
  cart: CART_TYPES,
@@ -3037,7 +3597,8 @@ var TYPES_BY_DOMAIN = {
3037
3597
  customers: CUSTOMERS_TYPES,
3038
3598
  payments: PAYMENTS_TYPES,
3039
3599
  helpers: HELPERS_TYPES,
3040
- inquiries: INQUIRIES_TYPES
3600
+ inquiries: INQUIRIES_TYPES,
3601
+ "modifier-groups": MODIFIER_GROUPS_TYPES
3041
3602
  };
3042
3603
  function getTypesByDomain(domain) {
3043
3604
  if (domain === "all") {
@@ -3103,7 +3664,12 @@ var GET_CODE_EXAMPLE_SCHEMA = {
3103
3664
  "reservation-countdown",
3104
3665
  "search-autocomplete-debounce",
3105
3666
  "i18n-set-locale",
3106
- "order-history-full"
3667
+ "order-history-full",
3668
+ "modifier-groups-render",
3669
+ "cart-add-with-selections",
3670
+ "modifier-validation-error-handling",
3671
+ "checkout-price-drift",
3672
+ "cart-item-modifier-display"
3107
3673
  ]).describe("The SDK operation to get a snippet for.")
3108
3674
  };
3109
3675
  var SNIPPETS = {
@@ -3111,7 +3677,10 @@ var SNIPPETS = {
3111
3677
  import { BrainerceClient } from 'brainerce';
3112
3678
 
3113
3679
  export const client = new BrainerceClient({
3114
- connectionId: process.env.BRAINERCE_CONNECTION_ID!, // vc_*
3680
+ // Read either env var name (the new one is preferred; the old one is a soft
3681
+ // alias kept for backwards compatibility \u2014 both are accepted by the SDK).
3682
+ salesChannelId:
3683
+ process.env.BRAINERCE_SALES_CHANNEL_ID! ?? process.env.BRAINERCE_CONNECTION_ID!, // vc_*
3115
3684
  });
3116
3685
 
3117
3686
  // If the store has i18n enabled, set the locale at app start based on
@@ -3175,7 +3744,7 @@ function selectVariant(product: Product, selections: Record<string, string>) {
3175
3744
  import { client } from './brainerce';
3176
3745
 
3177
3746
  // 1. Submit the address + email. Email is REQUIRED on the DTO.
3178
- const { shippingRates } = await client.setShippingAddress(checkoutId, {
3747
+ const { checkout, rates } = await client.setShippingAddress(checkoutId, {
3179
3748
  email,
3180
3749
  firstName,
3181
3750
  lastName,
@@ -3186,10 +3755,11 @@ const { shippingRates } = await client.setShippingAddress(checkoutId, {
3186
3755
  postalCode,
3187
3756
  country,
3188
3757
  });
3758
+ // rates = available shipping rates; checkout = updated checkout object
3189
3759
 
3190
3760
  // 2. Let the customer pick one of the returned rates.
3191
- const chosen = shippingRates[0]; // user selection
3192
- await client.setShippingMethod(checkoutId, chosen.id);
3761
+ const chosen = rates[0]; // user selection
3762
+ await client.selectShippingMethod(checkoutId, chosen.id);
3193
3763
 
3194
3764
  // 3. Re-fetch the checkout to get updated totals (tax may change after address).
3195
3765
  const checkout = await client.getCheckout(checkoutId);
@@ -3243,21 +3813,21 @@ if (providers.length === 0) {
3243
3813
  }
3244
3814
 
3245
3815
  const active = providers[0];`,
3246
- "checkout-stripe-confirm": `// Stripe confirm flow. Requires @stripe/stripe-js and @stripe/react-stripe-js
3247
- // (or the vanilla Stripe.js browser SDK).
3816
+ "checkout-stripe-confirm": `// Stripe confirm flow. Requires @stripe/stripe-js.
3248
3817
  import { loadStripe } from '@stripe/stripe-js';
3249
3818
  import { client } from './brainerce';
3250
3819
 
3251
3820
  const stripe = await loadStripe(stripePublicKey);
3252
3821
  if (!stripe) throw new Error('Stripe failed to load');
3253
3822
 
3254
- // The SDK returns a clientSecret via the provider's config after setPaymentMethod.
3255
- const { clientSecret } = await client.setPaymentMethod(checkoutId, {
3256
- providerId: stripeProvider.id,
3823
+ // Create a payment intent \u2014 returns clientSecret for Stripe Elements.
3824
+ const intent = await client.createPaymentIntent(checkoutId, {
3825
+ successUrl: \`\${window.location.origin}/order-confirmation?checkoutId=\${checkoutId}\`,
3826
+ cancelUrl: \`\${window.location.origin}/checkout?error=cancelled\`,
3257
3827
  });
3258
3828
 
3259
- // Use Stripe Elements to collect the card, then confirm:
3260
- const result = await stripe.confirmCardPayment(clientSecret, {
3829
+ // Use Stripe Elements to confirm:
3830
+ const result = await stripe.confirmCardPayment(intent.clientSecret, {
3261
3831
  payment_method: { card: cardElement },
3262
3832
  });
3263
3833
 
@@ -3266,21 +3836,21 @@ if (result.error) {
3266
3836
  return;
3267
3837
  }
3268
3838
 
3269
- // Success \u2014 redirect to your confirmation page with checkoutId in the URL.
3839
+ // Success \u2014 redirect to confirmation page.
3270
3840
  // The confirmation page runs handlePaymentSuccess + waitForOrder.
3271
3841
  window.location.assign(\`/order-confirmation?checkoutId=\${checkoutId}\`);`,
3272
3842
  "checkout-paypal-confirm": `// PayPal confirm flow. Use the PayPal JS SDK (render a PayPal button component).
3273
3843
  import { client } from './brainerce';
3274
3844
 
3275
- // When the PayPal button's onApprove fires with the PayPal order ID:
3276
- async function onPayPalApprove(paypalOrderId: string) {
3277
- await client.setPaymentMethod(checkoutId, {
3278
- providerId: paypalProvider.id,
3279
- providerData: { paypalOrderId },
3280
- });
3845
+ // Create a payment intent first \u2014 for PayPal this returns the PayPal order ID.
3846
+ const intent = await client.createPaymentIntent(checkoutId, {
3847
+ successUrl: \`\${window.location.origin}/order-confirmation?checkoutId=\${checkoutId}\`,
3848
+ cancelUrl: \`\${window.location.origin}/checkout?error=cancelled\`,
3849
+ });
3281
3850
 
3282
- // PayPal confirmation \u2014 the SDK captures the order server-side.
3283
- // After this resolves, redirect to confirmation.
3851
+ // Render a PayPal button using intent.clientSdk (provider SDK config).
3852
+ // When the PayPal button's onApprove fires, redirect to confirmation:
3853
+ function onPayPalApprove() {
3284
3854
  window.location.assign(\`/order-confirmation?checkoutId=\${checkoutId}\`);
3285
3855
  }`,
3286
3856
  "checkout-sandbox-confirm": `// Sandbox payment \u2014 store has sandboxPaymentsEnabled=true. No real charge.
@@ -3323,7 +3893,7 @@ async function registerAndMaybeVerify(input: {
3323
3893
  firstName: string;
3324
3894
  lastName: string;
3325
3895
  }) {
3326
- const result = await client.register(input);
3896
+ const result = await client.registerCustomer(input);
3327
3897
  if (result.requiresVerification) {
3328
3898
  // Route the user to your verify-email UI. Do NOT treat them as logged in.
3329
3899
  return { state: 'needs-verification' as const };
@@ -3344,7 +3914,7 @@ async function resendCode() {
3344
3914
  import { client } from './brainerce';
3345
3915
 
3346
3916
  async function login(email: string, password: string) {
3347
- const result = await client.login({ email, password });
3917
+ const result = await client.loginCustomer(email, password);
3348
3918
  if (result.requiresVerification) {
3349
3919
  return { state: 'needs-verification' as const };
3350
3920
  }
@@ -3379,25 +3949,37 @@ async function submitReset(token: string, newPassword: string) {
3379
3949
  "oauth-redirect-and-callback": `// OAuth sign-in. Redirect flow, NOT popup.
3380
3950
  import { client } from './brainerce';
3381
3951
 
3382
- // On your login / register UI:
3383
- const providers = await client.getAvailableOAuthProviders();
3384
- // Render one button per provider. On click, redirect the browser:
3385
- for (const p of providers) {
3386
- // button.onclick = () => window.location.assign(p.authorizationUrl)
3952
+ // Step 1: Get available provider names (strings: 'GOOGLE', 'FACEBOOK', 'GITHUB')
3953
+ const { providers } = await client.getAvailableOAuthProviders();
3954
+
3955
+ // Step 2: For each provider, fetch the authorization URL, then redirect.
3956
+ // Call this when the user clicks a provider button:
3957
+ async function redirectToOAuth(provider: string) {
3958
+ const { authorizationUrl } = await client.getOAuthAuthorizeUrl(provider, {
3959
+ redirectUrl: \`\${window.location.origin}/auth/callback\`,
3960
+ });
3961
+ window.location.href = authorizationUrl; // full-page redirect, NOT a popup
3387
3962
  }
3388
3963
 
3389
- // On your OAuth callback route (SERVER-SIDE / BFF):
3390
- // 1. Read token + oauth_success (or oauth_error) from query params.
3391
- // 2. If oauth_error: redirect to login with the error message.
3392
- // 3. If oauth_success: exchange the token for a session cookie (your BFF),
3393
- // then redirect the browser to /account or wherever.
3394
- // Do NOT allow the token to land in browser history as a query param.`,
3964
+ // Step 3: On your /auth/callback route, read token from URL params:
3965
+ const params = new URLSearchParams(window.location.search);
3966
+ const oauthError = params.get('oauth_error');
3967
+ const token = params.get('token');
3968
+ if (oauthError) {
3969
+ window.location.href = '/login?error=' + encodeURIComponent(oauthError);
3970
+ } else if (token) {
3971
+ client.setCustomerToken(token);
3972
+ window.location.href = '/account';
3973
+ }`,
3395
3974
  "coupon-apply-and-remove": `// Coupon input. Build the UI even if no coupons exist today \u2014 it auto-hides.
3975
+ // IMPORTANT: use applyCheckoutCoupon when a checkoutId is available (checkout page).
3976
+ // Using applyCoupon after checkout creation does NOT update the checkout total.
3396
3977
  import { client } from './brainerce';
3397
3978
 
3398
- async function applyCoupon(code: string) {
3979
+ // On cart page (no checkout session yet)
3980
+ async function applyCouponToCart(cartId: string, code: string) {
3399
3981
  try {
3400
- const cart = await client.applyCoupon(code);
3982
+ const cart = await client.applyCoupon(cartId, code);
3401
3983
  return { ok: true, cart };
3402
3984
  } catch (err) {
3403
3985
  // Invalid / expired / minimum not met. Surface the specific error.
@@ -3405,17 +3987,30 @@ async function applyCoupon(code: string) {
3405
3987
  }
3406
3988
  }
3407
3989
 
3408
- async function removeCoupon() {
3409
- const cart = await client.removeCoupon();
3410
- return cart;
3990
+ async function removeCouponFromCart(cartId: string) {
3991
+ return client.removeCoupon(cartId);
3992
+ }
3993
+
3994
+ // On checkout page (checkout session already exists \u2014 always prefer this)
3995
+ async function applyCouponToCheckout(checkoutId: string, code: string) {
3996
+ try {
3997
+ const checkout = await client.applyCheckoutCoupon(checkoutId, code);
3998
+ return { ok: true, checkout }; // checkout.total is already updated
3999
+ } catch (err) {
4000
+ return { ok: false, error: (err as Error).message };
4001
+ }
4002
+ }
4003
+
4004
+ async function removeCouponFromCheckout(checkoutId: string) {
4005
+ return client.removeCheckoutCoupon(checkoutId);
3411
4006
  }`,
3412
4007
  "reservation-countdown": `// Reservation countdown. Use the SDK's expiry timestamp \u2014 do NOT invent
3413
4008
  // your own timer state.
3414
4009
  import { client } from './brainerce';
3415
4010
 
3416
- function getRemainingMs(cart: { reservationExpiresAt?: string | null }): number {
3417
- if (!cart.reservationExpiresAt) return 0;
3418
- const expiry = new Date(cart.reservationExpiresAt).getTime();
4011
+ function getRemainingMs(cart: { reservation?: { expiresAt?: string } }): number {
4012
+ if (!cart.reservation?.expiresAt) return 0;
4013
+ const expiry = new Date(cart.reservation.expiresAt).getTime();
3419
4014
  return Math.max(0, expiry - Date.now());
3420
4015
  }
3421
4016
 
@@ -3442,8 +4037,8 @@ function onSearchChange(query: string, onResults: (items: unknown[]) => void) {
3442
4037
  return;
3443
4038
  }
3444
4039
  searchTimer = setTimeout(async () => {
3445
- const results = await client.searchProducts(query);
3446
- onResults(results);
4040
+ const { products } = await client.getSearchSuggestions(query);
4041
+ onResults(products);
3447
4042
  }, 300);
3448
4043
  }`,
3449
4044
  "i18n-set-locale": `// i18n \u2014 only if get-store-capabilities reports i18n.enabled.
@@ -3531,7 +4126,412 @@ for (const order of orders) {
3531
4126
 
3532
4127
  // All sections above are conditional. Absent data = section not rendered \u2014
3533
4128
  // never show empty placeholders. The create-brainerce-store template's
3534
- // src/components/account/order-history.tsx is the reference implementation.`
4129
+ // src/components/account/order-history.tsx is the reference implementation.`,
4130
+ "modifier-groups-render": `// Render modifier groups on the product detail page (toppings / sauce /
4131
+ // build-your-own). The data lives on product.modifierGroups \u2014 only present
4132
+ // for restaurant / customizable products.
4133
+ import { useState } from 'react';
4134
+ import type { ModifierGroup, Product } from 'brainerce';
4135
+
4136
+ function ModifierGroups({ product }: { product: Product }) {
4137
+ const groups: ModifierGroup[] = product.modifierGroups ?? [];
4138
+ if (groups.length === 0) return null; // not a customizable product
4139
+
4140
+ // Local state: selected modifier IDs per group, in click-order.
4141
+ const [selections, setSelections] = useState<Record<string, string[]>>(() =>
4142
+ buildInitialSelections(groups)
4143
+ );
4144
+
4145
+ return (
4146
+ <>
4147
+ {groups.map((group) => {
4148
+ // PRD \xA77.2.2: max === 0 means "hidden for this variant" \u2014 skip entirely.
4149
+ if (group.max === 0) return null;
4150
+
4151
+ const isSingle = group.selectionType === 'SINGLE';
4152
+ const inputType = isSingle ? 'radio' : 'checkbox';
4153
+ const sorted = [...group.modifiers].sort((a, b) => a.position - b.position);
4154
+ const picks = selections[group.id] ?? [];
4155
+ const usedFree = Math.min(picks.length, group.freeQuantity);
4156
+
4157
+ const toggle = (modifierId: string, checked: boolean) => {
4158
+ setSelections((prev) => {
4159
+ const cur = prev[group.id] ?? [];
4160
+ if (isSingle) return { ...prev, [group.id]: checked ? [modifierId] : [] };
4161
+ if (checked) {
4162
+ if (group.max != null && cur.length >= group.max) return prev; // at cap
4163
+ return { ...prev, [group.id]: [...cur, modifierId] };
4164
+ }
4165
+ return { ...prev, [group.id]: cur.filter((id) => id !== modifierId) };
4166
+ });
4167
+ };
4168
+
4169
+ return (
4170
+ <fieldset key={group.id}>
4171
+ <legend>
4172
+ {group.name}{group.required && ' *'}
4173
+ </legend>
4174
+ {group.freeQuantity > 0 && (
4175
+ <p>{usedFree} of {group.freeQuantity} free</p>
4176
+ )}
4177
+ {sorted.map((m) => (
4178
+ <label key={m.id}>
4179
+ <input
4180
+ type={inputType}
4181
+ name={\`mg-\${group.id}\`}
4182
+ value={m.id}
4183
+ checked={picks.includes(m.id)}
4184
+ disabled={!m.available}
4185
+ onChange={(e) => toggle(m.id, e.target.checked)}
4186
+ />
4187
+ {m.name}
4188
+ {parseFloat(m.priceDelta) !== 0 && (
4189
+ <span> {parseFloat(m.priceDelta) > 0 ? '+' : ''}{m.priceDelta}</span>
4190
+ )}
4191
+ {!m.available && <span> (Sold out)</span>}
4192
+ </label>
4193
+ ))}
4194
+ </fieldset>
4195
+ );
4196
+ })}
4197
+ </>
4198
+ );
4199
+ }
4200
+
4201
+ // Initial state: prefer per-attach defaultModifierIds; fall back to
4202
+ // modifier.isDefault flags. Filter sold-out modifiers.
4203
+ function buildInitialSelections(groups: ModifierGroup[]): Record<string, string[]> {
4204
+ const out: Record<string, string[]> = {};
4205
+ for (const group of groups) {
4206
+ if (group.max === 0) continue;
4207
+ const fromAttach = group.defaultModifierIds ?? [];
4208
+ const fromIsDefault = group.modifiers
4209
+ .filter((m) => m.isDefault && m.available)
4210
+ .map((m) => m.id);
4211
+ const merged =
4212
+ fromAttach.length > 0
4213
+ ? fromAttach.filter((id) => group.modifiers.some((m) => m.id === id && m.available))
4214
+ : fromIsDefault;
4215
+ const capped = group.selectionType === 'SINGLE' ? merged.slice(0, 1) : merged;
4216
+ if (capped.length > 0) out[group.id] = capped;
4217
+ }
4218
+ return out;
4219
+ }
4220
+
4221
+ // PRICE-DELTA RULES:
4222
+ // - "5.00" \u2192 +$5 added to unit price
4223
+ // - "-2.00" \u2192 downsell: subtracts $2; never consumes a free slot
4224
+ // - "0.00" \u2192 free option; render no price label
4225
+ // Server enforces unitPrice >= 0; rejects negative deltas combined with
4226
+ // referencedProductId. Do NOT compute the line total client-side \u2014 the
4227
+ // server runs free-allocation and returns cart.items[i].unitPrice.
4228
+
4229
+ `,
4230
+ "cart-add-with-selections": `// Pass modifier-group selections on add-to-cart. The server validates
4231
+ // against the effective rules and computes the final unitPrice (base +
4232
+ // paid modifiers, after the free-allocation policy runs).
4233
+ import { client } from './brainerce';
4234
+ import type { ModifierSelection } from 'brainerce';
4235
+
4236
+ async function addPizzaToCart(
4237
+ productId: string,
4238
+ variantId: string,
4239
+ selectionsByGroup: Record<string, string[]>
4240
+ ) {
4241
+ // Adapter: shape used in component state \u2192 wire format the SDK accepts.
4242
+ const selections: ModifierSelection[] = Object.entries(selectionsByGroup)
4243
+ .filter(([, modifierIds]) => modifierIds.length > 0)
4244
+ .map(([modifierGroupId, modifierIds]) => ({ modifierGroupId, modifierIds }));
4245
+
4246
+ // modifierIds is in CLICK-ORDER \u2014 used by the SELECTION_ORDER free-allocation
4247
+ // policy. Don't sort it; preserve the order the customer clicked.
4248
+
4249
+ const cart = await client.smartAddToCart({
4250
+ productId,
4251
+ variantId,
4252
+ quantity: 1,
4253
+ selections,
4254
+ });
4255
+
4256
+ // Read the snapshot back off the new cart line.
4257
+ const line = cart.items[cart.items.length - 1];
4258
+ // line.modifiers \u2192 CartItemModifierLine[] with { modifierId, name, priceDelta, freeApplied }
4259
+ // line.modifiersTotal \u2192 decimal string of paid (non-free) deltas
4260
+ // line.unitPrice \u2192 final unit price including all paid modifiers
4261
+ console.log('Free applied:', line.modifiers?.filter((m) => m.freeApplied).map((m) => m.name));
4262
+ }
4263
+
4264
+ // EDITING SELECTIONS LATER (PRD \xA77.2.3 \u2014 idempotent replacement):
4265
+ // PATCH the cart item with a fresh selections array. The server deletes ALL
4266
+ // existing CartItemModifier rows and recreates them in the same transaction.
4267
+ // To leave selections untouched (e.g., quantity-only update), omit them.
4268
+ async function changeToppings(cartId: string, itemId: string, newToppingIds: string[]) {
4269
+ await client.updateCartItem(cartId, itemId, {
4270
+ quantity: 1,
4271
+ selections: [{ modifierGroupId: 'mg_toppings', modifierIds: newToppingIds }],
4272
+ });
4273
+ }
4274
+
4275
+ // NESTED COMBOS (depth \u2264 3): when a picked modifier carries
4276
+ // referencedProductId, fetch that product's own modifierGroups, collect the
4277
+ // nested selections, and pass them keyed by the PARENT modifier id.
4278
+ async function addCombo(productId: string, mainBurger: string) {
4279
+ await client.smartAddToCart({
4280
+ productId,
4281
+ quantity: 1,
4282
+ selections: [
4283
+ { modifierGroupId: 'mg_main', modifierIds: [mainBurger] }, // the burger
4284
+ { modifierGroupId: 'mg_drink', modifierIds: ['m_cola'] },
4285
+ ],
4286
+ nestedByModifierId: {
4287
+ [mainBurger]: [
4288
+ { modifierGroupId: 'mg_doneness', modifierIds: ['m_medium'] },
4289
+ { modifierGroupId: 'mg_cheese', modifierIds: ['m_cheddar'] },
4290
+ ],
4291
+ },
4292
+ });
4293
+ }`,
4294
+ "modifier-validation-error-handling": `// Surface MODIFIER_VALIDATION_FAILED to the user. The SDK throws
4295
+ // BrainerceError on HTTP 400; the structured envelope is on .details.
4296
+ import { client } from './brainerce';
4297
+ import type { ModifierSelection } from 'brainerce';
4298
+
4299
+ interface ModifierValidationError {
4300
+ code:
4301
+ | 'REQUIRED_GROUP_MISSING'
4302
+ | 'MIN_SELECTIONS_NOT_MET'
4303
+ | 'MAX_SELECTIONS_EXCEEDED'
4304
+ | 'SINGLE_GROUP_MULTIPLE_PICKS'
4305
+ | 'UNKNOWN_MODIFIER'
4306
+ | 'UNKNOWN_GROUP'
4307
+ | 'MODIFIER_DISABLED_FOR_VARIANT'
4308
+ | 'MODIFIER_NOT_AVAILABLE'
4309
+ | 'NESTED_DEPTH_EXCEEDED'
4310
+ | 'NESTED_REQUIRES_PRODUCT_REF'
4311
+ | 'INVALID_PRICE_DELTA'
4312
+ | 'MODIFIER_PRICE_FLOOR_VIOLATED';
4313
+ message: string;
4314
+ modifierGroupId?: string;
4315
+ modifierId?: string;
4316
+ }
4317
+
4318
+ async function tryAddToCart(productId: string, selections: ModifierSelection[]) {
4319
+ try {
4320
+ await client.smartAddToCart({ productId, quantity: 1, selections });
4321
+ return { ok: true as const };
4322
+ } catch (err) {
4323
+ const e = err as {
4324
+ statusCode?: number;
4325
+ details?: { code?: string; errors?: ModifierValidationError[] };
4326
+ };
4327
+
4328
+ if (e.statusCode === 400 && e.details?.code === 'MODIFIER_VALIDATION_FAILED') {
4329
+ // Surface each issue inline. groupId / modifierId let you highlight the
4330
+ // specific control the customer needs to fix.
4331
+ const issues = e.details.errors ?? [];
4332
+ const messages = issues.map((i) => formatIssue(i));
4333
+ return { ok: false as const, issues, messages };
4334
+ }
4335
+
4336
+ // Some other error (network, 500, etc.) \u2014 re-throw to a generic handler.
4337
+ throw err;
4338
+ }
4339
+ }
4340
+
4341
+ function formatIssue(issue: ModifierValidationError): string {
4342
+ switch (issue.code) {
4343
+ case 'REQUIRED_GROUP_MISSING':
4344
+ return 'Please pick at least one option from this group.';
4345
+ case 'MIN_SELECTIONS_NOT_MET':
4346
+ case 'MAX_SELECTIONS_EXCEEDED':
4347
+ case 'SINGLE_GROUP_MULTIPLE_PICKS':
4348
+ return issue.message; // server's message already says "Pick at least N" / "Pick at most N"
4349
+ case 'UNKNOWN_MODIFIER':
4350
+ case 'UNKNOWN_GROUP':
4351
+ case 'MODIFIER_DISABLED_FOR_VARIANT':
4352
+ case 'MODIFIER_NOT_AVAILABLE':
4353
+ // The catalog moved under us \u2014 refetch the product and ask the customer
4354
+ // to re-pick. These codes mean the option simply isn't valid anymore.
4355
+ return 'This option is no longer available \u2014 please refresh.';
4356
+ case 'NESTED_DEPTH_EXCEEDED':
4357
+ case 'NESTED_REQUIRES_PRODUCT_REF':
4358
+ return issue.message; // bug in your renderer \u2014 never go past 3 levels
4359
+ case 'INVALID_PRICE_DELTA':
4360
+ return issue.message; // shouldn't happen for SDK callers \u2014 server-validated on save
4361
+ case 'MODIFIER_PRICE_FLOOR_VIOLATED':
4362
+ // The server reports a generic message \u2014 internals never leak. Show a
4363
+ // friendly error and let the customer remove a downsell.
4364
+ return 'Cannot apply more discounts on this item.';
4365
+ }
4366
+ }
4367
+
4368
+ // CLIENT-SIDE PRE-FLIGHT (UX):
4369
+ // You can mirror these checks before calling addToCart to give faster
4370
+ // feedback, but the SERVER is authoritative. Always treat the 400 envelope
4371
+ // as the source of truth.
4372
+ function preflightSelections(
4373
+ groups: { id: string; name: string; min: number; max?: number | null; required: boolean; selectionType: 'SINGLE' | 'MULTIPLE'; }[],
4374
+ selections: Record<string, string[]>
4375
+ ): string | null {
4376
+ for (const g of groups) {
4377
+ if (g.max === 0) continue; // disabled-for-variant
4378
+ const picks = selections[g.id] ?? [];
4379
+ if (g.required && picks.length === 0) return \`\${g.name} is required\`;
4380
+ if (picks.length < g.min) return \`Pick at least \${g.min} from \${g.name}\`;
4381
+ if (g.max != null && picks.length > g.max) return \`Pick at most \${g.max} from \${g.name}\`;
4382
+ if (g.selectionType === 'SINGLE' && picks.length > 1) return \`Only one option allowed in \${g.name}\`;
4383
+ }
4384
+ return null;
4385
+ }`,
4386
+ "checkout-price-drift": `// PRICE_DRIFT \u2014 a product's price changed between add-to-cart and checkout.
4387
+ // The server returns HTTP 400 with code "PRICE_DRIFT" when it detects that
4388
+ // the stored unitPrice no longer matches the live price.
4389
+ //
4390
+ // Strategy: catch the error \u2192 show "prices updated" dialog \u2192
4391
+ // call refreshCartSnapshots() to re-snapshot all live prices \u2192
4392
+ // retry createCheckout once.
4393
+
4394
+ import { client } from './brainerce-client';
4395
+
4396
+ interface CheckoutOptions {
4397
+ cartId: string;
4398
+ }
4399
+
4400
+ async function createCheckoutSafe(opts: CheckoutOptions) {
4401
+ try {
4402
+ // createCheckout only takes cartId (+ optional customerId, selectedItemIds).
4403
+ // Shipping address, shipping method, and payment are set via separate calls.
4404
+ return await client.createCheckout({ cartId: opts.cartId });
4405
+ } catch (err: unknown) {
4406
+ if (!isApiError(err, 'PRICE_DRIFT')) throw err;
4407
+
4408
+ // Prices changed \u2014 refresh snapshots then retry once.
4409
+ // refreshCartSnapshots updates every cart item's unitPrice to the current
4410
+ // live price (base price + any modifier deltas) so the next checkout call
4411
+ // will pass the drift guard.
4412
+ await client.refreshCartSnapshots(opts.cartId);
4413
+
4414
+ // After refreshing, re-fetch the cart so your UI shows the updated prices
4415
+ // before you retry, giving the customer a chance to review.
4416
+ const updatedCart = await client.getCart(opts.cartId);
4417
+
4418
+ // Signal to the UI layer that prices changed so it can show a dialog.
4419
+ throw Object.assign(new Error('PRICES_UPDATED'), {
4420
+ code: 'PRICES_UPDATED',
4421
+ updatedCart,
4422
+ });
4423
+ }
4424
+ }
4425
+
4426
+ function isApiError(err: unknown, code: string): boolean {
4427
+ return (
4428
+ typeof err === 'object' &&
4429
+ err !== null &&
4430
+ 'code' in err &&
4431
+ (err as { code: string }).code === code
4432
+ );
4433
+ }
4434
+
4435
+ // --- UI integration example (framework-neutral) ---
4436
+ //
4437
+ // async function handlePlaceOrder() {
4438
+ // try {
4439
+ // const checkout = await createCheckoutSafe({ cartId, ... });
4440
+ // router.push(\`/order-confirmation?checkoutId=\${checkout.id}\`);
4441
+ // } catch (err: unknown) {
4442
+ // if (isApiError(err, 'PRICES_UPDATED')) {
4443
+ // const { updatedCart } = err as { updatedCart: Cart };
4444
+ // showPricesUpdatedDialog(updatedCart); // show diff, let user confirm
4445
+ // return;
4446
+ // }
4447
+ // showGenericError(err);
4448
+ // }
4449
+ // }
4450
+ //
4451
+ // The dialog should:
4452
+ // 1. Show which items changed price (compare old vs new unitPrice)
4453
+ // 2. Offer "Continue with new prices" (calls createCheckoutSafe again)
4454
+ // and "Go back to cart" (returns to cart page)
4455
+ // 3. NOT silently retry \u2014 the customer must acknowledge the price change.`,
4456
+ "cart-item-modifier-display": `// Rendering cart items that have modifier selections.
4457
+ // Each cart item has a \`modifiers\` array \u2014 each entry records the modifier
4458
+ // name, the price delta at time of add-to-cart, and whether it was free
4459
+ // (inside the group's freeQuantity allowance).
4460
+ //
4461
+ // Typical display:
4462
+ // Margherita \u20AA45.00
4463
+ // Extra cheese +\u20AA5.00
4464
+ // Mushrooms free
4465
+ // No onions \u2014
4466
+ // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
4467
+ // Base \u20AA45.00 + Modifiers \u20AA5.00 = \u20AA50.00
4468
+
4469
+ import { formatPrice } from 'brainerce';
4470
+
4471
+ interface CartItemModifier {
4472
+ modifierId: string;
4473
+ name: string;
4474
+ priceDeltaAtTime: number; // in store currency units, e.g. 5.00
4475
+ freeApplied: boolean; // true when inside the group's freeQuantity
4476
+ }
4477
+
4478
+ interface CartItem {
4479
+ id: string;
4480
+ name: string;
4481
+ unitPrice: number; // base + all chargeable modifier deltas, as stored
4482
+ quantity: number;
4483
+ modifiers?: CartItemModifier[];
4484
+ currency: string; // e.g. 'ILS', 'USD'
4485
+ }
4486
+
4487
+ function renderCartItem(item: CartItem): string {
4488
+ const currency = item.currency;
4489
+ const lines: string[] = [];
4490
+
4491
+ lines.push(\`\${item.name} \${formatPrice(item.unitPrice, { currency })}\`);
4492
+
4493
+ const chargeableModifiers = (item.modifiers ?? []).filter(
4494
+ (m) => !m.freeApplied && m.priceDeltaAtTime !== 0
4495
+ );
4496
+ const freeModifiers = (item.modifiers ?? []).filter((m) => m.freeApplied);
4497
+ const zeroModifiers = (item.modifiers ?? []).filter(
4498
+ (m) => !m.freeApplied && m.priceDeltaAtTime === 0
4499
+ );
4500
+
4501
+ for (const m of chargeableModifiers) {
4502
+ const sign = m.priceDeltaAtTime > 0 ? '+' : '';
4503
+ lines.push(\` \${m.name} \${sign}\${formatPrice(m.priceDeltaAtTime, { currency })}\`);
4504
+ }
4505
+ for (const m of freeModifiers) {
4506
+ lines.push(\` \${m.name} free\`);
4507
+ }
4508
+ for (const m of zeroModifiers) {
4509
+ lines.push(\` \${m.name} \u2014\`);
4510
+ }
4511
+
4512
+ // Price breakdown: only show when there are chargeable modifiers
4513
+ const modifiersTotal = chargeableModifiers.reduce(
4514
+ (sum, m) => sum + m.priceDeltaAtTime,
4515
+ 0
4516
+ );
4517
+ if (modifiersTotal !== 0) {
4518
+ // unitPrice already includes modifiers \u2014 derive base for display only
4519
+ const basePrice = item.unitPrice - modifiersTotal;
4520
+ const base = formatPrice(basePrice, { currency }) as string;
4521
+ const mods = formatPrice(modifiersTotal, { currency }) as string;
4522
+ const total = formatPrice(item.unitPrice, { currency }) as string;
4523
+ lines.push(\`Base \${base} + Modifiers \${mods} = \${total}\`);
4524
+ }
4525
+
4526
+ return lines.join('\\n');
4527
+ }
4528
+
4529
+ // IMPORTANT: unitPrice already includes all chargeable modifier deltas.
4530
+ // Do NOT add modifier prices on top of unitPrice when computing line totals \u2014
4531
+ // multiply unitPrice \xD7 quantity directly.
4532
+ function lineTotal(item: CartItem): number {
4533
+ return item.unitPrice * item.quantity;
4534
+ }`
3535
4535
  };
3536
4536
  async function handleGetCodeExample(args) {
3537
4537
  const snippet = SNIPPETS[args.operation];
@@ -3647,13 +4647,27 @@ function getCandidateApiUrls() {
3647
4647
 
3648
4648
  // src/tools/get-store-info.ts
3649
4649
  var GET_STORE_INFO_NAME = "get-store-info";
3650
- var GET_STORE_INFO_DESCRIPTION = "Fetch live store information from the Brainerce API using a connection ID. Returns the channel display name (what the user sees in their dashboard), parent store name, currency, and language. Use this to personalize the store being built \u2014 prefer the channel name for user-facing text since a single Brainerce store can have multiple sales channels.";
4650
+ var GET_STORE_INFO_DESCRIPTION = "Fetch live store information from the Brainerce API using a sales channel ID. Returns the channel display name (what the user sees in their dashboard), parent store name, currency, and language. Use this to personalize the store being built \u2014 prefer the channel name for user-facing text since a single Brainerce store can have multiple sales channels.";
3651
4651
  var GET_STORE_INFO_SCHEMA = {
3652
- connectionId: import_zod4.z.string().describe("Vibe-coded connection ID (starts with vc_)")
4652
+ salesChannelId: import_zod4.z.string().optional().describe("Sales channel ID (starts with vc_)"),
4653
+ /** @deprecated alias of salesChannelId */
4654
+ connectionId: import_zod4.z.string().optional().describe("Deprecated alias of salesChannelId \u2014 kept for backwards compat")
3653
4655
  };
3654
4656
  async function handleGetStoreInfo(args) {
4657
+ const id = args.salesChannelId ?? args.connectionId;
4658
+ if (!id) {
4659
+ return {
4660
+ content: [{ type: "text", text: "Error: salesChannelId is required" }],
4661
+ isError: true
4662
+ };
4663
+ }
4664
+ if (!args.salesChannelId && args.connectionId) {
4665
+ console.warn(
4666
+ "get-store-info: `connectionId` is deprecated \u2014 use `salesChannelId` instead"
4667
+ );
4668
+ }
3655
4669
  try {
3656
- const resolved = await resolveStoreInfo(args.connectionId, getCandidateApiUrls());
4670
+ const resolved = await resolveStoreInfo(id, getCandidateApiUrls());
3657
4671
  return {
3658
4672
  content: [
3659
4673
  {
@@ -3666,7 +4680,8 @@ async function handleGetStoreInfo(args) {
3666
4680
  storeName: resolved.info.storeName,
3667
4681
  currency: resolved.info.currency,
3668
4682
  language: resolved.info.language,
3669
- connectionId: args.connectionId,
4683
+ salesChannelId: id,
4684
+ connectionId: id,
3670
4685
  apiBaseUrl: resolved.apiBaseUrl
3671
4686
  },
3672
4687
  null,
@@ -3751,9 +4766,11 @@ async function resolveStoreCapabilities(connectionId, candidateUrls) {
3751
4766
 
3752
4767
  // src/tools/get-store-capabilities.ts
3753
4768
  var GET_STORE_CAPABILITIES_NAME = "get-store-capabilities";
3754
- var GET_STORE_CAPABILITIES_DESCRIPTION = "Get live store capabilities and configured features for a vibe-coded connection. Returns what payment providers, OAuth, shipping, discounts, and other features are set up. Use this to discover what your store supports and what pages/components to build.";
4769
+ var GET_STORE_CAPABILITIES_DESCRIPTION = "Get live store capabilities and configured features for a sales channel. Returns what payment providers, OAuth, shipping, discounts, and other features are set up. Use this to discover what your store supports and what pages/components to build.";
3755
4770
  var GET_STORE_CAPABILITIES_SCHEMA = {
3756
- connectionId: import_zod5.z.string().describe("Vibe-coded connection ID (starts with vc_)")
4771
+ salesChannelId: import_zod5.z.string().optional().describe("Sales channel ID (starts with vc_)"),
4772
+ /** @deprecated alias of salesChannelId */
4773
+ connectionId: import_zod5.z.string().optional().describe("Deprecated alias of salesChannelId \u2014 kept for backwards compat")
3757
4774
  };
3758
4775
  function formatCapabilities(caps) {
3759
4776
  const lines = [];
@@ -3866,15 +4883,27 @@ function formatCapabilities(caps) {
3866
4883
  }
3867
4884
  if (suggestions.length === 0) {
3868
4885
  suggestions.push(
3869
- "Start by building the required features. Use get-required-features with this connectionId for the checklist."
4886
+ "Start by building the required features. Use get-required-features with this salesChannelId for the checklist."
3870
4887
  );
3871
4888
  }
3872
4889
  suggestions.forEach((s) => lines.push(`- ${s}`));
3873
4890
  return lines.join("\n");
3874
4891
  }
3875
4892
  async function handleGetStoreCapabilities(args) {
4893
+ const id = args.salesChannelId ?? args.connectionId;
4894
+ if (!id) {
4895
+ return {
4896
+ content: [{ type: "text", text: "Error: salesChannelId is required" }],
4897
+ isError: true
4898
+ };
4899
+ }
4900
+ if (!args.salesChannelId && args.connectionId) {
4901
+ console.warn(
4902
+ "get-store-capabilities: `connectionId` is deprecated \u2014 use `salesChannelId` instead"
4903
+ );
4904
+ }
3876
4905
  try {
3877
- const resolved = await resolveStoreCapabilities(args.connectionId, getCandidateApiUrls());
4906
+ const resolved = await resolveStoreCapabilities(id, getCandidateApiUrls());
3878
4907
  return {
3879
4908
  content: [{ type: "text", text: formatCapabilities(resolved.capabilities) }]
3880
4909
  };
@@ -3979,7 +5008,7 @@ var RULES = {
3979
5008
  tokens: {
3980
5009
  title: "Auth tokens & BFF pattern",
3981
5010
  body: `- NEVER store customer auth tokens in \`localStorage\` directly from client code. Use a Backend-For-Frontend proxy: the server receives the token, sets an HttpOnly cookie, and the client reads session state from an endpoint like \`/api/auth/me\`.
3982
- - NEVER put the admin API key (\`brainerce_*\`) in client code. It is a server-only secret. Client code uses \`connectionId\` or storefront endpoints.
5011
+ - NEVER put the admin API key (\`brainerce_*\`) in client code. It is a server-only secret. Client code uses \`salesChannelId\` (or the deprecated \`connectionId\` alias) or storefront endpoints.
3983
5012
  - OAuth callbacks arrive with the token in URL params. Extract it SERVER-side and exchange it for a session cookie before redirecting to the app. Do not let the token land in browser history.
3984
5013
  - On logout, clear the BFF session server-side. A client-only logout that forgets to tell the server leaves an active session on the server until it expires.`
3985
5014
  },
@@ -4047,21 +5076,21 @@ var FLOWS = {
4047
5076
  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\`.
4048
5077
  2. **Submit the address to get shipping rates.**
4049
5078
  \`\`\`ts
4050
- const { shippingRates } = await client.setShippingAddress(checkoutId, {
5079
+ const { checkout, rates } = await client.setShippingAddress(checkoutId, {
4051
5080
  email,
4052
5081
  firstName,
4053
5082
  lastName,
4054
5083
  line1, line2, city, region, postalCode, country,
4055
5084
  });
4056
5085
  \`\`\`
4057
- The response includes the available shipping rates for that address + the store's zones.
5086
+ \`rates\` = available shipping rates for that address + the store's zones. \`checkout\` = updated checkout object.
4058
5087
  3. **Let the customer pick a rate**, then persist the selection:
4059
5088
  \`\`\`ts
4060
- await client.setShippingMethod(checkoutId, rateId);
5089
+ await client.selectShippingMethod(checkoutId, rateId);
4061
5090
  \`\`\`
4062
- 4. **Fetch payment providers for this checkout:**
5091
+ 4. **Fetch payment providers:**
4063
5092
  \`\`\`ts
4064
- const providers = await client.getPaymentProviders(checkoutId);
5093
+ const providers = await client.getPaymentProviders();
4065
5094
  \`\`\`
4066
5095
  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.
4067
5096
  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.
@@ -4077,28 +5106,28 @@ Never bypass these steps. Never call \`submitGuestOrder\` / \`createOrder\` \u20
4077
5106
  "auth-register": {
4078
5107
  title: "Registration",
4079
5108
  body: `1. **Collect** email, password, first name, last name. Enforce strong passwords client-side: 8+ chars, upper, lower, number, special.
4080
- 2. **Call register:**
5109
+ 2. **Call registerCustomer:**
4081
5110
  \`\`\`ts
4082
- const result = await client.register({ email, password, firstName, lastName });
5111
+ const result = await client.registerCustomer({ email, password, firstName, lastName });
4083
5112
  \`\`\`
4084
5113
  3. **Branch on \`result.requiresVerification\`:**
4085
- - If \`true\`: route the user to your verify-email UI. Do NOT treat them as logged in yet.
4086
- - If \`false\`: the user is logged in. Store the token via your BFF session and route to the account area.
5114
+ - 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.
5115
+ - If \`false\`: call \`client.setCustomerToken(result.token)\` and route to the account area.
4087
5116
  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()\`.
4088
- 5. **After verifyEmail resolves:** the user is now logged in. Persist the session via your BFF and route to the account area.
5117
+ 5. **After verifyEmail resolves:** call \`client.setCustomerToken(result.token)\` \u2014 the user is now logged in \u2014 then route to the account area.
4089
5118
 
4090
5119
  Build the verify-email step EVEN IF the store currently has verification disabled. It auto-hides; store owners enable it later.`
4091
5120
  },
4092
5121
  "auth-login": {
4093
5122
  title: "Login",
4094
5123
  body: `1. **Collect** email + password.
4095
- 2. **Call login:**
5124
+ 2. **Call loginCustomer:**
4096
5125
  \`\`\`ts
4097
- const result = await client.login({ email, password });
5126
+ const result = await client.loginCustomer(email, password);
4098
5127
  \`\`\`
4099
5128
  3. **Branch on \`result.requiresVerification\`:**
4100
5129
  - If \`true\`: route to verify-email. The user must complete verification before accessing account features.
4101
- - If \`false\`: persist the session via your BFF and route to the previous page (or account area).
5130
+ - If \`false\`: call \`client.setCustomerToken(result.token)\` and route to the previous page (or account area).
4102
5131
  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.
4103
5132
  5. **On error**, render the specific message (invalid credentials, rate limited, account disabled) \u2014 never swallow.`
4104
5133
  },
@@ -4123,15 +5152,24 @@ Build both steps EVEN IF the store has no email provider configured today \u2014
4123
5152
  },
4124
5153
  oauth: {
4125
5154
  title: "OAuth sign-in",
4126
- body: `1. **Render available providers:**
5155
+ body: `1. **Get available provider names:**
4127
5156
  \`\`\`ts
4128
- const providers = await client.getAvailableOAuthProviders();
5157
+ const { providers } = await client.getAvailableOAuthProviders();
5158
+ // providers = ['GOOGLE', 'FACEBOOK', 'GITHUB'] (strings, not objects with authorizationUrl)
4129
5159
  \`\`\`
4130
- Each provider has \`{ provider: 'GOOGLE' | 'FACEBOOK' | 'GITHUB', authorizationUrl }\`.
4131
- 2. **Build a button for each** that redirects the browser to \`authorizationUrl\`. Do NOT open a popup; the full-page redirect is required.
4132
- 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.
4133
- 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.
4134
- 5. **On \`oauth_error\`:** redirect to login with an error message.
5160
+ 2. **For each provider, fetch the authorization URL:**
5161
+ \`\`\`ts
5162
+ const { authorizationUrl } = await client.getOAuthAuthorizeUrl(provider, {
5163
+ redirectUrl: \`\${window.location.origin}/auth/callback\`,
5164
+ });
5165
+ window.location.href = authorizationUrl; // full-page redirect, NOT a popup
5166
+ \`\`\`
5167
+ 3. **On the callback page** the URL contains \`token\` + \`oauth_success\` (or \`oauth_error\`) query params. Extract and apply the token:
5168
+ \`\`\`ts
5169
+ const token = new URLSearchParams(location.search).get('token');
5170
+ if (token) client.setCustomerToken(token); // then redirect to account
5171
+ \`\`\`
5172
+ 4. **On \`oauth_error\`:** redirect to login with an error message.
4135
5173
 
4136
5174
  Build the OAuth button region AND the callback handler even when no providers are configured. They auto-hide.`
4137
5175
  },
@@ -4153,7 +5191,7 @@ Build the OAuth button region AND the callback handler even when no providers ar
4153
5191
 
4154
5192
  - **Cart ID persistence:** the SDK stores the cart ID across reloads. You do not need to write cart-to-localStorage code yourself.
4155
5193
  - **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).
4156
- - **Writes:** use \`client.addToCart\`, \`client.updateCartItem\`, \`client.removeCartItem\`, \`client.applyCoupon\`, \`client.removeCoupon\`. After each mutation the SDK returns the updated cart.
5194
+ - **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.
4157
5195
  - **Totals:** call \`getCartTotals(cart)\` \u2014 do NOT read \`cart.total\`. The helper understands taxes, shipping, and discounts.
4158
5196
  - **\`smartGetCart()\`** returns \`CartWithIncludes\` (extends \`Cart\`). All carts are server-side. Pass \`{ include: ['recommendations', 'upgrades', 'bundles'] }\` to fetch extras in one request.
4159
5197
  - **NEVER mutate cart state outside SDK helpers.** Any hand-rolled cart update risks desync with the reservation timer and the checkout flow.`
@@ -4272,18 +5310,20 @@ ${flow.body}`
4272
5310
  // src/tools/get-required-features.ts
4273
5311
  var import_zod9 = require("zod");
4274
5312
  var GET_REQUIRED_FEATURES_NAME = "get-required-features";
4275
- var GET_REQUIRED_FEATURES_DESCRIPTION = "Get the functional coverage checklist for a Brainerce store. Returns user-capability-level features (what users must be able to do) rather than pages or file paths. Every feature marked mandatory must exist in the finished build, even when the underlying capability is currently disabled \u2014 those features auto-hide and store owners enable them later. Pass a connectionId to tune the checklist to the live store; without it, returns the generic complete-store checklist.";
5313
+ var GET_REQUIRED_FEATURES_DESCRIPTION = "Get the functional coverage checklist for a Brainerce store. Returns user-capability-level features (what users must be able to do) rather than pages or file paths. Every feature marked mandatory must exist in the finished build, even when the underlying capability is currently disabled \u2014 those features auto-hide and store owners enable them later. Pass a salesChannelId to tune the checklist to the live store; without it, returns the generic complete-store checklist.";
4276
5314
  var GET_REQUIRED_FEATURES_SCHEMA = {
4277
- connectionId: import_zod9.z.string().optional().describe(
4278
- "Vibe-coded connection ID (starts with vc_). Optional \u2014 without it, returns the generic checklist."
4279
- )
5315
+ salesChannelId: import_zod9.z.string().optional().describe(
5316
+ "Sales channel ID (starts with vc_). Optional \u2014 without it, returns the generic checklist."
5317
+ ),
5318
+ /** @deprecated alias of salesChannelId */
5319
+ connectionId: import_zod9.z.string().optional().describe("Deprecated alias of salesChannelId \u2014 kept for backwards compat")
4280
5320
  };
4281
5321
  var FEATURES = [
4282
5322
  {
4283
5323
  id: "browse-products",
4284
5324
  title: "Browse, filter, and search products",
4285
- description: "Users can list products with pagination, apply category / price / attribute filters, sort by name / price / newest, and run a search with autocomplete. Must handle empty states and loading states.",
4286
- sdk: "client.getProducts({ page, limit, filters, sort }), client.searchProducts(query)",
5325
+ 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.",
5326
+ sdk: "client.getProducts({ page, limit, filters, sort, metafields }), client.getPublicMetafieldDefinitions(), client.getSearchSuggestions(query)",
4287
5327
  mandatory: "mandatory"
4288
5328
  },
4289
5329
  {
@@ -4313,7 +5353,7 @@ var FEATURES = [
4313
5353
  id: "cart-coupons",
4314
5354
  title: "Apply and remove coupon codes",
4315
5355
  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.",
4316
- sdk: "client.applyCoupon(code), client.removeCoupon()",
5356
+ 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)",
4317
5357
  mandatory: "mandatory",
4318
5358
  capabilityFlag: "hasCoupons",
4319
5359
  whenDisabledNote: "Store has no coupons configured today. Build the UI anyway \u2014 it auto-hides until the store owner creates a coupon."
@@ -4330,7 +5370,7 @@ var FEATURES = [
4330
5370
  id: "checkout",
4331
5371
  title: "Complete a full checkout end-to-end",
4332
5372
  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.",
4333
- sdk: "client.setShippingAddress, client.setShippingMethod, client.getPaymentProviders, provider-specific confirm, client.handlePaymentSuccess, client.waitForOrder",
5373
+ sdk: "client.setShippingAddress (returns { checkout, rates }), client.selectShippingMethod, client.getPaymentProviders(), provider-specific confirm, client.handlePaymentSuccess, client.waitForOrder",
4334
5374
  flowRef: "checkout",
4335
5375
  mandatory: "mandatory"
4336
5376
  },
@@ -4346,7 +5386,7 @@ var FEATURES = [
4346
5386
  id: "register",
4347
5387
  title: "Register a new account with email verification",
4348
5388
  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.',
4349
- sdk: "client.register({email, password, firstName, lastName}), client.verifyEmail(code), client.resendVerificationEmail()",
5389
+ sdk: "client.registerCustomer({email, password, firstName, lastName}), client.verifyEmail(code), client.resendVerificationEmail()",
4350
5390
  flowRef: "auth-register",
4351
5391
  mandatory: "mandatory",
4352
5392
  capabilityFlag: "oauth",
@@ -4356,7 +5396,7 @@ var FEATURES = [
4356
5396
  id: "login",
4357
5397
  title: "Log in with email / password and handle verification branch",
4358
5398
  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).",
4359
- sdk: "client.login({email, password})",
5399
+ sdk: "client.loginCustomer(email, password)",
4360
5400
  flowRef: "auth-login",
4361
5401
  mandatory: "mandatory"
4362
5402
  },
@@ -4389,14 +5429,14 @@ var FEATURES = [
4389
5429
  id: "header",
4390
5430
  title: "Global header with cart count and search",
4391
5431
  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.",
4392
- sdk: "client.getCart() for the count, client.searchProducts(query) for autocomplete",
5432
+ sdk: "client.getCart() for the count, client.getSearchSuggestions(query) for autocomplete",
4393
5433
  mandatory: "mandatory"
4394
5434
  },
4395
5435
  {
4396
5436
  id: "discount-banners",
4397
5437
  title: "Show active discount banners and badges",
4398
5438
  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.",
4399
- sdk: "client.getDiscountBanners(), getProductDiscountBadge(product)",
5439
+ sdk: "client.getDiscountBanners(), client.getProductDiscountBadge(productId)",
4400
5440
  mandatory: "mandatory",
4401
5441
  capabilityFlag: "hasDiscountRules",
4402
5442
  whenDisabledNote: "Store has no discount rules active. Build the banner and badge components anyway \u2014 they auto-hide."
@@ -4498,10 +5538,16 @@ function renderCapabilitiesSummary(caps) {
4498
5538
  return lines.join("\n");
4499
5539
  }
4500
5540
  async function handleGetRequiredFeatures(args) {
5541
+ const id = args.salesChannelId ?? args.connectionId;
5542
+ if (!args.salesChannelId && args.connectionId) {
5543
+ console.warn(
5544
+ "get-required-features: `connectionId` is deprecated \u2014 use `salesChannelId` instead"
5545
+ );
5546
+ }
4501
5547
  let caps = null;
4502
- if (args.connectionId) {
5548
+ if (id) {
4503
5549
  try {
4504
- const resolved = await resolveStoreCapabilities(args.connectionId, getCandidateApiUrls());
5550
+ const resolved = await resolveStoreCapabilities(id, getCandidateApiUrls());
4505
5551
  caps = resolved.capabilities;
4506
5552
  } catch {
4507
5553
  caps = null;
@@ -4513,11 +5559,11 @@ async function handleGetRequiredFeatures(args) {
4513
5559
  );
4514
5560
  if (caps) {
4515
5561
  sections.push(renderCapabilitiesSummary(caps));
4516
- } else if (args.connectionId) {
5562
+ } else if (id) {
4517
5563
  sections.push(
4518
5564
  `## Live store capabilities
4519
5565
 
4520
- Could not fetch live capabilities for \`${args.connectionId}\`. Proceeding with the generic checklist. Call \`get-store-capabilities\` separately if you want a targeted checklist.`
5566
+ Could not fetch live capabilities for \`${id}\`. Proceeding with the generic checklist. Call \`get-store-capabilities\` separately if you want a targeted checklist.`
4521
5567
  );
4522
5568
  }
4523
5569
  sections.push("## Features");