@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/index.js CHANGED
@@ -119,7 +119,7 @@ npm install brainerce
119
119
  import { BrainerceClient } from 'brainerce';
120
120
 
121
121
  export const client = new BrainerceClient({
122
- connectionId: '${connectionId}',
122
+ salesChannelId: '${connectionId}',
123
123
  });
124
124
 
125
125
  // Cart helpers \u2014 save cart ID to localStorage
@@ -221,7 +221,7 @@ async function startCheckout() {
221
221
  ### Full Checkout Flow (Multi-Provider)
222
222
 
223
223
  1. Customer fills cart
224
- 2. Customer optionally applies coupon (cart page)
224
+ 2. Customer optionally applies coupon on cart page \u2192 \`applyCoupon(cartId, code)\`
225
225
  3. Detect payment providers \u2192 \`getPaymentProviders()\`
226
226
  4. Start checkout session (\`startGuestCheckout()\` or \`createCheckout()\`)
227
227
  5. Set shipping address (includes required email) \u2192 \`setShippingAddress()\`
@@ -239,7 +239,7 @@ import { Elements, PaymentElement, useStripe, useElements } from '@stripe/react-
239
239
 
240
240
  function CheckoutPage() {
241
241
  const [paymentData, setPaymentData] = useState<{
242
- clientSecret: string; provider: string; checkoutId: string;
242
+ clientSecret: string; provider: string; checkoutId: string; clientSdk?: { renderType: string };
243
243
  } | null>(null);
244
244
  const [stripePromise, setStripePromise] = useState<ReturnType<typeof loadStripe> | null>(null);
245
245
  const [paypalClientId, setPaypalClientId] = useState<string | null>(null);
@@ -291,12 +291,17 @@ function CheckoutPage() {
291
291
  }
292
292
 
293
293
  // Step 4: Create payment intent \u2014 returns provider type!
294
+ // Pass saveCard: true when the customer ticked "save my card for next time" \u2014
295
+ // only honored for logged-in customers (not guests). The vaulted card then
296
+ // appears in client.listSavedPaymentMethods(storeId, customerId) and can be
297
+ // charged off-session via subscription / one-click checkout flows.
294
298
  const paymentIntent = await client.createPaymentIntent(checkoutId, {
295
299
  successUrl: \`\${window.location.origin}/order-confirmation?checkout_id=\${checkoutId}\`,
296
300
  cancelUrl: \`\${window.location.origin}/checkout?error=payment_cancelled\`,
301
+ // saveCard: customerOptedIn, // optional opt-in
297
302
  });
298
303
 
299
- setPaymentData({ clientSecret: paymentIntent.clientSecret, provider: paymentIntent.provider, checkoutId });
304
+ setPaymentData({ clientSecret: paymentIntent.clientSecret, provider: paymentIntent.provider, checkoutId, clientSdk: paymentIntent.clientSdk });
300
305
 
301
306
  // Step 5: Initialize the correct payment provider
302
307
  if (paymentIntent.provider === 'stripe' && stripeProvider) {
@@ -414,8 +419,10 @@ function PaymentIframe({ clientSecret, checkoutId }: { clientSecret: string; che
414
419
  }
415
420
  if (data?.type === 'brainerce:redirect' && typeof data.url === 'string') {
416
421
  // Top-level navigation (e.g. Bit). ALWAYS validate against an allowlist
417
- // before navigating \u2014 never trust the URL blindly.
418
- if (isTrustedPaymentUrl(data.url)) { window.top!.location.href = data.url; }
422
+ // before navigating \u2014 never trust the URL blindly. The SDK ships with
423
+ // a maintained list of payment-provider hosts; prefer it over a local
424
+ // copy so 'npm update brainerce' picks up new providers automatically.
425
+ if (isAllowedPaymentUrl(data.url)) { window.top!.location.href = data.url; }
419
426
  }
420
427
  if (data?.type === 'brainerce:payment-complete') {
421
428
  // Payment done \u2014 redirect to confirmation page which verifies server-side
@@ -455,15 +462,10 @@ function PaymentIframe({ clientSecret, checkoutId }: { clientSecret: string; che
455
462
  );
456
463
  }
457
464
 
458
- // Allowlist of origins you trust for top-level payment redirects.
459
- function isTrustedPaymentUrl(url: string): boolean {
460
- try {
461
- const u = new URL(url);
462
- if (u.protocol !== 'https:') return false;
463
- // Add your provider hostnames here. Example: Cardcom for Bit express-pay.
464
- return u.hostname === 'cardcom.solutions' || u.hostname.endsWith('.cardcom.solutions');
465
- } catch { return false; }
466
- }
465
+ // Allowlist check is provided by the SDK \u2014 covers Stripe, PayPal, Cardcom,
466
+ // Meshulam, Grow, CreditGuard, plus Brainerce-hosted embed shells. To extend
467
+ // for a self-hosted PSP, pass { extraHosts: ['my-psp.example.com'] }.
468
+ import { isAllowedPaymentUrl } from 'brainerce';
467
469
  \`\`\`
468
470
 
469
471
  ### PayPal Payment Form
@@ -751,6 +753,80 @@ Provider-specific install notes:
751
753
  - **Grow:** No SDK needed \u2014 JS SDK loaded via \`clientSdk.scriptUrl\`. Supports credit cards, Bit, Apple Pay, Google Pay.
752
754
  - **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.`;
753
755
  }
756
+ function getSavedPaymentMethodsSection() {
757
+ return `## Saved Payment Methods (vaulted cards)
758
+
759
+ When a logged-in customer ticks "save my card for next time", the platform vaults the
760
+ card with the underlying provider and stores a reference. Vaulted cards can later be
761
+ charged off-session \u2014 typically via subscription / one-click checkout features built
762
+ on top of this foundation.
763
+
764
+ ### Opt-in at checkout
765
+
766
+ Pass \`saveCard: true\` to \`createPaymentIntent\` when the customer chose to save.
767
+ **Only honored for logged-in customers** \u2014 anonymous (guest) checkouts silently
768
+ skip vaulting because there's no Customer record to attach the resulting saved
769
+ method to.
770
+
771
+ \`\`\`typescript
772
+ const intent = await client.createPaymentIntent(checkoutId, {
773
+ successUrl,
774
+ cancelUrl,
775
+ saveCard: true, // optional \u2014 only when customer ticked the box AND is logged in
776
+ });
777
+ \`\`\`
778
+
779
+ After a successful charge, the saved card appears in the customer's profile.
780
+
781
+ ### Listing saved cards (storefront / customer account page)
782
+
783
+ Show the customer their saved cards on a "Manage Payment Methods" page in their
784
+ account. The platform returns display-only metadata \u2014 last4, brand, expiry,
785
+ default flag, status. The underlying provider token is encrypted at rest and
786
+ NEVER returned through the SDK.
787
+
788
+ \`\`\`typescript
789
+ const methods = await client.listSavedPaymentMethods(storeId, customerId);
790
+
791
+ methods.forEach((m) => {
792
+ console.log(\`\${m.brand} ending in \${m.last4} (expires \${m.expMonth}/\${m.expYear})\`);
793
+ if (m.isDefault) console.log(' \u21B3 default');
794
+ if (m.status === 'expired') console.log(' \u21B3 expired \u2014 please add a new card');
795
+ });
796
+ \`\`\`
797
+
798
+ ### Removing a saved card
799
+
800
+ \`\`\`typescript
801
+ await client.removeSavedPaymentMethod(storeId, customerId, methodId);
802
+ \`\`\`
803
+
804
+ Hard-deletes the row from the platform DB. The provider may still hold the
805
+ underlying token internally \u2014 we don't issue a delete-at-provider call because
806
+ not every provider supports it. From the platform's perspective the token is
807
+ gone; subsequent charges fail.
808
+
809
+ ### Provider support matrix
810
+
811
+ | Provider | Save card on first charge | Charge saved card off-session |
812
+ |---|---|---|
813
+ | Cardcom | \u2705 (Operation: ChargeAndCreateToken) | \u2705 |
814
+ | PayPal | \u2705 (Vault API: store_in_vault) | \u2705 |
815
+ | Stripe | (separate workstream \u2014 Brainerce doesn't ship a Stripe payment app yet) | \u2014 |
816
+ | Grow | \u274C \u2014 returns 501 \`token_storage_unavailable\` | \u274C |
817
+
818
+ When a provider doesn't support tokenization, the platform surfaces a 409 with
819
+ \`code: 'token_storage_unavailable'\`. Storefronts should hide the "save card"
820
+ checkbox when the active provider doesn't support it.
821
+
822
+ ### Charging off-session
823
+
824
+ Charging a saved card from the storefront is **not** part of this foundation \u2014
825
+ that's the job of the Subscription / one-click checkout features built on top.
826
+ For now, charges run through the standard \`createPaymentIntent\` flow. The
827
+ saved-method foundation makes those features possible without further platform
828
+ changes.`;
829
+ }
754
830
  function getProductsSection(_currency) {
755
831
  return `## Products & Variants
756
832
 
@@ -771,6 +847,18 @@ const filtered = await client.getProducts({
771
847
  categories: ['cat_123'], minPrice: 10, maxPrice: 100,
772
848
  sortBy: 'price', sortOrder: 'asc',
773
849
  });
850
+
851
+ // Filter by custom fields (metafields). Only fields the merchant marked
852
+ // \`filterable: true\` are honored; supported types are SELECT, MULTI_SELECT,
853
+ // BOOLEAN. AND across keys, OR within a key.
854
+ const byCustom = await client.getProducts({
855
+ metafields: { color: ['red', 'blue'], in_stock: ['true'] },
856
+ });
857
+
858
+ // Discover which custom fields are filterable for the current store:
859
+ const { definitions } = await client.getPublicMetafieldDefinitions();
860
+ const facets = definitions.filter(d => d.filterable);
861
+ // Render a checkbox group per SELECT/MULTI_SELECT, a switch per BOOLEAN.
774
862
  \`\`\`
775
863
 
776
864
  ### i18n \u2014 translated fields come back on every request
@@ -823,6 +911,10 @@ function ProductPage({ product, storeInfo }: { product: Product; storeInfo: Stor
823
911
  ? getVariantPrice(selectedVariant, product.basePrice).toString()
824
912
  : product.salePrice || product.basePrice;
825
913
 
914
+ // \u2705 For catalog cards: use pre-computed priceMin/priceMax instead of iterating variants
915
+ // product.priceMin / product.priceMax are always set for VARIABLE products with explicit variant prices.
916
+ // product.priceVaries is true when the range should be shown as "\u20AA49 \u2013 \u20AA199".
917
+
826
918
  // Build attribute buttons (size, color, etc.)
827
919
  const allOptions = product.variants?.map(v => getVariantOptions(v)) || [];
828
920
  const attrNames = [...new Set(allOptions.flatMap(opts => opts.map(o => o.name)))];
@@ -1037,6 +1129,7 @@ const total = cart.total;
1037
1129
 
1038
1130
  ### Coupons
1039
1131
 
1132
+ **On the cart page** (before checkout is created):
1040
1133
  \`\`\`typescript
1041
1134
  // Apply coupon \u2014 returns updated Cart with discountAmount
1042
1135
  const updatedCart = await client.applyCoupon(cartId, 'SAVE20');
@@ -1044,13 +1137,24 @@ console.log(updatedCart.discountAmount); // "10.00"
1044
1137
  console.log(updatedCart.couponCode); // "SAVE20"
1045
1138
 
1046
1139
  // Remove coupon
1047
- const updatedCart = await client.removeCoupon(cartId);
1140
+ await client.removeCoupon(cartId);
1048
1141
 
1049
1142
  // Calculate totals including discount
1050
1143
  const totals = getCartTotals(cart); // { subtotal, discount, shipping, total }
1051
1144
  \`\`\`
1052
1145
 
1053
- 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.
1146
+ **On the checkout page** (after checkout session exists \u2014 ALWAYS use this when checkoutId is available):
1147
+ \`\`\`typescript
1148
+ // Applies to cart AND updates checkout totals in one call
1149
+ const checkout = await client.applyCheckoutCoupon(checkoutId, 'SAVE20');
1150
+ console.log(checkout.discountAmount); // "10.00"
1151
+ console.log(checkout.total); // correctly updated total
1152
+
1153
+ // Remove coupon from checkout
1154
+ await client.removeCheckoutCoupon(checkoutId);
1155
+ \`\`\`
1156
+
1157
+ > \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.
1054
1158
 
1055
1159
  ### \u26A0\uFE0F Checkout Order Summary \u2014 Use checkout.lineItems, NOT cart.items!
1056
1160
 
@@ -1140,22 +1244,24 @@ const { upgrades } = await client.getCartUpgrades(cartId);
1140
1244
  // Show inline banner per cart item if upgrade exists
1141
1245
  \`\`\`
1142
1246
 
1143
- **Bundle offers** (discounted complementary products configured by the store owner):
1247
+ **Bundle offers** (N-product bundles configured by the store owner \u2014 productIds[0] triggers the offer, productIds[1..] are offered together at a discount):
1144
1248
  \`\`\`typescript
1145
1249
  import type { CartBundlesResponse } from 'brainerce';
1146
1250
  const { bundles } = await client.getCartBundles(cartId);
1147
- // bundles[].bundleProduct \u2014 the product to offer
1148
- // bundles[].originalPrice / bundles[].discountedPrice \u2014 prices
1251
+ // bundles[].triggerProductId \u2014 already in cart, activates the offer
1252
+ // bundles[].productIds \u2014 full bundle composition (length >= 2)
1253
+ // bundles[].offeredProducts[] \u2014 products customer hasn't added yet
1254
+ // each with originalPrice + discountedPrice
1255
+ // bundles[].totalOriginalPrice / totalDiscountedPrice \u2014 sums across offeredProducts
1149
1256
  // bundles[].discountType ('PERCENTAGE' | 'FIXED_AMOUNT') + discountValue
1150
- // bundles[].requiresVariantSelection \u2014 true if customer must pick a variant
1151
- // bundles[].lockedVariant \u2014 set if admin pre-selected a specific variant
1152
- // bundles[].bundleProduct.variants \u2014 available variants (only when requiresVariantSelection)
1153
1257
 
1154
- // Add a bundle to cart (applies the discount automatically):
1258
+ // Accept a bundle (adds every offered product not yet in cart at the discount):
1155
1259
  await client.addBundleToCart(cartId, bundleOfferId);
1156
- // For products with variants \u2014 pass the selected variantId:
1157
- await client.addBundleToCart(cartId, bundleOfferId, selectedVariantId);
1158
- // Remove a bundle from cart:
1260
+ // If some offered products have variants, pass per-product variant selections:
1261
+ await client.addBundleToCart(cartId, bundleOfferId, {
1262
+ [variantProductId]: selectedVariantId,
1263
+ });
1264
+ // Remove an accepted bundle (removes every cart item linked to that bundle):
1159
1265
  await client.removeBundleFromCart(cartId, bundleOfferId);
1160
1266
  // Detect already-added bundles: check cart.items for metadata?.isBundleItem === true
1161
1267
  \`\`\`
@@ -1211,11 +1317,12 @@ Always check the flag before rendering: \`if (storeInfo?.upsell?.featureName !==
1211
1317
  | CartRecommendationSection | products/ | Cross-sell grid at bottom of cart page |
1212
1318
  | FreeShippingBar | cart/ | Progress bar toward free shipping threshold |
1213
1319
  | CartUpgradeBanner | cart/ | Inline "Upgrade to X for +$Y" per cart item |
1214
- | CartBundleOfferCard | cart/ | Discounted bundle offer card in cart (with inline variant selector for variable products) |
1320
+ | CartBundleOfferCard | cart/ | N-product bundle offer card in cart \u2014 lists every offered product with its discounted price and an "Add bundle" button |
1215
1321
  | OrderBumpCard | checkout/ | Checkbox add-on card in checkout sidebar (with inline variant selector for variable products) |
1216
1322
 
1217
1323
  ProductRecommendation: \`id\`, \`name\`, \`slug\`, \`basePrice\`, \`salePrice\`, \`images\`, \`type\`, \`inventory\`, \`relationType\`, \`variants?\` (when variant selection needed).
1218
- OrderBump/CartBundleOffer: includes \`requiresVariantSelection\` (boolean) and \`lockedVariant?\` (\`{ id, name, attributes }\`).`;
1324
+ OrderBump: includes \`requiresVariantSelection\` (boolean) and \`lockedVariant?\` (\`{ id, name, attributes }\`).
1325
+ 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\`.`;
1219
1326
  }
1220
1327
  function getProductCustomizationFieldsSection() {
1221
1328
  return `## Product Customization Fields (buyer input on product page)
@@ -1321,6 +1428,157 @@ When the order is created, each line's customization values are snapshotted onto
1321
1428
  - Using a raw external URL (not from \`/customization-upload\`) for \`IMAGE\` / \`GALLERY\` \u2192 HTTP 400
1322
1429
  - Assuming \`enumValues\` is present for all types \u2014 only \`SELECT\` / \`MULTI_SELECT\` require it`;
1323
1430
  }
1431
+ function getModifierGroupsSection() {
1432
+ return `## Modifier Groups (toppings, sauce, build-your-own)
1433
+
1434
+ 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.
1435
+
1436
+ If \`product.modifierGroups\` is empty or missing, render the product page normally and skip everything below.
1437
+
1438
+ ### Wire shape on \`GET /products/:id\`
1439
+
1440
+ \`\`\`typescript
1441
+ import type { Product, ModifierGroup, Modifier, ModifierSelection } from 'brainerce';
1442
+
1443
+ const product = await client.getProductBySlug(slug);
1444
+ const groups: ModifierGroup[] = product.modifierGroups ?? [];
1445
+
1446
+ // Each group looks like:
1447
+ // {
1448
+ // id: 'mg_toppings',
1449
+ // attachmentId: 'pmg_01', // ProductModifierGroup row \u2014 used for attach updates
1450
+ // name: 'Toppings', // customer-facing
1451
+ // internalName?: string, // ADMIN ONLY \u2014 never present in storefront responses
1452
+ // selectionType: 'SINGLE' | 'MULTIPLE',
1453
+ // min: number, // effective (overrides already applied)
1454
+ // max: number | null, // null = unlimited; **0 = group hidden for this variant**
1455
+ // freeQuantity: number, // first N picks at no extra cost
1456
+ // required: boolean,
1457
+ // freeAllocationPolicy: 'EXPENSIVE_FREE' | 'CHEAPEST_FREE' | 'SELECTION_ORDER',
1458
+ // defaultModifierIds: string[], // pre-checked on first render
1459
+ // modifiers: Array<{
1460
+ // id: 'm_olive',
1461
+ // name: 'Olives',
1462
+ // priceDelta: '5.00', // DECIMAL STRING \u2014 never JSON Number
1463
+ // position: 0,
1464
+ // isDefault: false, // pre-check this option even if not in defaultModifierIds
1465
+ // available: true, // false \u2192 "sold out", disabled in UI
1466
+ // referencedProductId?: string, // nested-combo target (depth \u2264 3)
1467
+ // }>,
1468
+ // }
1469
+ \`\`\`
1470
+
1471
+ **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.
1472
+
1473
+ ### Render: SINGLE \u2192 radio, MULTIPLE \u2192 checkbox
1474
+
1475
+ Walk \`groups\` and pick the input type by \`selectionType\`. Sort modifiers by \`position\`.
1476
+
1477
+ \`\`\`typescript
1478
+ for (const group of groups) {
1479
+ if (group.max === 0) continue; // disabled-for-variant convention \u2014 skip entirely
1480
+ const inputType = group.selectionType === 'SINGLE' ? 'radio' : 'checkbox';
1481
+ const sorted = [...group.modifiers].sort((a, b) => a.position - b.position);
1482
+ // Render fieldset \u2192 legend with name + " *" if required \u2192 list of sorted options.
1483
+ // Each option: input(type=inputType, name=group.id, value=modifier.id),
1484
+ // disabled when modifier.available === false, with a "Sold out" badge.
1485
+ }
1486
+ \`\`\`
1487
+
1488
+ When \`group.freeQuantity > 0\`, show a running counter so the customer understands the rule:
1489
+ \`\`\`
1490
+ {Math.min(picks.length, group.freeQuantity)} of {group.freeQuantity} free
1491
+ \`\`\`
1492
+
1493
+ 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.
1494
+
1495
+ ### Initial state
1496
+
1497
+ 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.
1498
+
1499
+ ### Pass selections on add-to-cart
1500
+
1501
+ \`\`\`typescript
1502
+ const selections: ModifierSelection[] = [
1503
+ { modifierGroupId: 'mg_bread', modifierIds: ['m_thick'] },
1504
+ { modifierGroupId: 'mg_toppings', modifierIds: ['m_olive', 'm_mushroom', 'm_bacon', 'm_egg'] },
1505
+ ];
1506
+
1507
+ await client.smartAddToCart({
1508
+ productId: product.id,
1509
+ variantId: selectedVariant?.id,
1510
+ quantity: 1,
1511
+ selections,
1512
+ });
1513
+ \`\`\`
1514
+
1515
+ \`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:
1516
+
1517
+ \`\`\`typescript
1518
+ // cart.items[i].modifiers \u2014 same shape as ModifierSelection but with snapshot data
1519
+ [
1520
+ { modifierId: 'm_olive', name: 'Olives', priceDelta: '5.00', freeApplied: true },
1521
+ { modifierId: 'm_mushroom', name: 'Mushrooms', priceDelta: '5.00', freeApplied: true },
1522
+ { modifierId: 'm_bacon', name: 'Bacon', priceDelta: '7.00', freeApplied: true },
1523
+ { modifierId: 'm_egg', name: 'Egg', priceDelta: '6.00', freeApplied: false },
1524
+ ]
1525
+ // + cart.items[i].modifiersTotal \u2014 sum of paid (non-free) deltas as a decimal string
1526
+ \`\`\`
1527
+
1528
+ \`freeApplied: true\` means the modifier consumed a free slot \u2014 render with a "free" badge in the cart UI.
1529
+
1530
+ ### Editing selections after add-to-cart (idempotent)
1531
+
1532
+ \`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).
1533
+
1534
+ \`\`\`typescript
1535
+ await client.updateCartItem(cart.id, itemId, {
1536
+ quantity: 1,
1537
+ selections: [{ modifierGroupId: 'mg_toppings', modifierIds: ['m_olive', 'm_mushroom'] }],
1538
+ });
1539
+ \`\`\`
1540
+
1541
+ ### Validation envelope
1542
+
1543
+ When the payload is invalid the server returns HTTP 400 with a structured envelope. The SDK exposes it on \`BrainerceError.details\`:
1544
+
1545
+ \`\`\`typescript
1546
+ try {
1547
+ await client.smartAddToCart({ productId, quantity: 1, selections });
1548
+ } catch (err) {
1549
+ const e = err as { statusCode?: number; details?: { code?: string; errors?: Array<{ code: string; message: string; modifierGroupId?: string; modifierId?: string }> } };
1550
+ if (e.statusCode === 400 && e.details?.code === 'MODIFIER_VALIDATION_FAILED') {
1551
+ for (const issue of e.details.errors ?? []) {
1552
+ // issue.code is one of: REQUIRED_GROUP_MISSING | MIN_SELECTIONS_NOT_MET |
1553
+ // MAX_SELECTIONS_EXCEEDED | SINGLE_GROUP_MULTIPLE_PICKS | UNKNOWN_MODIFIER |
1554
+ // UNKNOWN_GROUP | MODIFIER_DISABLED_FOR_VARIANT | MODIFIER_NOT_AVAILABLE |
1555
+ // NESTED_DEPTH_EXCEEDED | NESTED_REQUIRES_PRODUCT_REF | INVALID_PRICE_DELTA |
1556
+ // MODIFIER_PRICE_FLOOR_VIOLATED
1557
+ console.error(issue.message);
1558
+ }
1559
+ }
1560
+ }
1561
+ \`\`\`
1562
+
1563
+ 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.
1564
+
1565
+ ### Disable-for-variant convention (PRD \xA77.2.2)
1566
+
1567
+ 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.
1568
+
1569
+ ### Common mistakes
1570
+
1571
+ - Treating \`priceDelta\` as a number \u2192 arithmetic precision bugs. Always strings; use \`parseFloat\` only at display time.
1572
+ - Computing the line total client-side \u2192 diverges from the server's free-allocation. Render the server's \`unitPrice\` / \`modifiers[]\` / \`modifiersTotal\` instead.
1573
+ - Rendering a group with \`max: 0\` \u2192 it's the variant-disable signal; treat as absent.
1574
+ - 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.
1575
+ - Building \`selections\` keyed by \`modifier.name\` \u2192 must be \`modifierGroupId\` + \`modifierIds[]\` (the IDs, not names).
1576
+ - Sending \`modifiers\` instead of \`selections\` on add-to-cart \u2192 \`modifiers\` is the response field; the request key is \`selections\`.
1577
+
1578
+ ### Restaurant features (advanced)
1579
+
1580
+ 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.`;
1581
+ }
1324
1582
  function getInventorySection() {
1325
1583
  return `## Inventory, Stock Display & Reservation Countdown
1326
1584
 
@@ -1716,7 +1974,7 @@ subsequent SDK call sends the \`Accept-Language\` header automatically:
1716
1974
  \`\`\`typescript
1717
1975
  import { BrainerceClient } from 'brainerce';
1718
1976
 
1719
- const client = new BrainerceClient({ connectionId: 'vc_...' });
1977
+ const client = new BrainerceClient({ salesChannelId: 'vc_...' });
1720
1978
  client.setLocale('he'); // done \u2014 all calls are now in Hebrew
1721
1979
  \`\`\`
1722
1980
 
@@ -1886,7 +2144,40 @@ const admin = new BrainerceClient({ apiKey: 'brainerce_...' });
1886
2144
  // Email: getEmailTemplates(), createEmailTemplate()
1887
2145
  // Conflicts: getSyncConflicts(), resolveSyncConflict()
1888
2146
  // OAuth: getOAuthProviders(), configureOAuthProvider()
1889
- \`\`\``;
2147
+ \`\`\`
2148
+
2149
+ ### Per-Channel Publishing
2150
+
2151
+ Categories, tags, brands, and metafield definitions are gated to specific
2152
+ vibe-coded sites \u2014 same control already exposed for products and coupons.
2153
+ **Explicit opt-in:** an entity is visible to a vibe-coded site only if it
2154
+ has been explicitly published to that connection. Entities with no publish
2155
+ rows are invisible to every site (the merchant must publish them via the
2156
+ dashboard or the admin SDK).
2157
+
2158
+ \`\`\`typescript
2159
+ // Publish to a specific vibe-coded site (admin mode):
2160
+ await admin.publishCategoryToVibeCodedSite('cat_id', 'conn_id');
2161
+ await admin.publishTagToVibeCodedSite('tag_id', 'conn_id');
2162
+ await admin.publishBrandToVibeCodedSite('brand_id', 'conn_id');
2163
+ await admin.publishMetafieldDefinitionToVibeCodedSite('def_id', 'conn_id');
2164
+
2165
+ // Unpublish (entity stays visible to other sites unless they also have publishes):
2166
+ await admin.unpublishCategoryFromVibeCodedSite('cat_id', 'conn_id');
2167
+ // ...same for tag/brand/metafield definition.
2168
+
2169
+ // Read which sites an entity is published to via list/get responses:
2170
+ const cat = await admin.getCategory('cat_id');
2171
+ cat.vibeCodedPublishes; // [{ connection: { id, name, connectionId } }, ...]
2172
+ \`\`\`
2173
+
2174
+ Cross-account isolation is enforced server-side: a publish call only
2175
+ succeeds when entity and connection belong to the same account. Cross-account
2176
+ calls fail with \`404 Not Found\`.
2177
+
2178
+ The vibe-coded read endpoints (used by storefront SDK calls in \`connectionId\`
2179
+ mode) automatically filter by these junction tables, so storefronts never see
2180
+ entities that weren't published to their site.`;
1890
2181
  }
1891
2182
  function getContactInquiriesSection() {
1892
2183
  return `## Contact Inquiries & Forms (optional)
@@ -1961,7 +2252,7 @@ const form = await brainerce.contactForms.get('main', 'en');
1961
2252
  // \u2192 {
1962
2253
  // id, key, name, description?, submitButton, successMessage,
1963
2254
  // fields: [{ key, type, label, placeholder?, helpText?, isRequired,
1964
- // enumValues?, validation?, defaultValue? }, ...]
2255
+ // enumValues?, validation?, defaultValue?, width? }, ...]
1965
2256
  // }
1966
2257
 
1967
2258
  // Submit a keyed payload. Unknown keys are stripped, required fields are
@@ -1987,6 +2278,20 @@ stripped server-side.
1987
2278
  Render every field type the merchant can pick. Keep this as a \`<DynamicField>\`
1988
2279
  component so the form is fully driven by \`schema.fields\`.
1989
2280
 
2281
+ **Layout.** Each field carries an optional \`width\` hint:
2282
+
2283
+ | \`field.width\` | Meaning | Grid style (6-column grid) |
2284
+ | ------------- | ------- | -------------------------- |
2285
+ | \`'FULL'\` (default) | Full row | \`grid-column: span 6\` |
2286
+ | \`'HALF'\` | Half row | \`grid-column: span 3\` |
2287
+ | \`'THIRD'\` | One-third row | \`grid-column: span 2\` |
2288
+
2289
+ Stack fields to full width on small screens (< ~640 px).
2290
+
2291
+ **Required fields.** Show a red asterisk on required labels, validate
2292
+ **client-side** before calling \`createInquiry()\`, and show inline error
2293
+ messages. Do NOT rely only on the server returning 400.
2294
+
1990
2295
  \`\`\`tsx
1991
2296
  import type { ContactFormPublic, ContactFormPublicField } from 'brainerce';
1992
2297
 
@@ -2004,13 +2309,24 @@ function isEmpty(v: FieldValue): boolean {
2004
2309
  return v === false;
2005
2310
  }
2006
2311
 
2312
+ // Map width \u2192 CSS grid column span (6-column grid)
2313
+ function widthClass(w?: string): string {
2314
+ switch (w) {
2315
+ case 'HALF': return 'grid-col-half'; // grid-column: span 3; on sm+, full on mobile
2316
+ case 'THIRD': return 'grid-col-third'; // grid-column: span 2; on sm+, full on mobile
2317
+ default: return 'grid-col-full'; // grid-column: span 6
2318
+ }
2319
+ }
2320
+
2007
2321
  function DynamicField({
2008
2322
  field,
2009
2323
  value,
2324
+ error,
2010
2325
  onChange,
2011
2326
  }: {
2012
2327
  field: ContactFormPublicField;
2013
2328
  value: FieldValue;
2329
+ error?: string;
2014
2330
  onChange: (v: FieldValue) => void;
2015
2331
  }) {
2016
2332
  const id = \`contact-\${field.key}\`;
@@ -2025,37 +2341,40 @@ function DynamicField({
2025
2341
  </label>
2026
2342
  );
2027
2343
  const help = field.helpText ? <p className="mt-1 text-xs opacity-70">{field.helpText}</p> : null;
2344
+ const errorEl = error ? <p className="mt-1 text-xs text-red-500">{error}</p> : null;
2028
2345
 
2346
+ // Outer div uses widthClass to control grid-column span
2029
2347
  switch (field.type) {
2030
2348
  case 'TEXTAREA':
2031
- 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>);
2349
+ 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>);
2032
2350
  case 'EMAIL':
2033
- 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>);
2351
+ 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>);
2034
2352
  case 'PHONE':
2035
- 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>);
2353
+ 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>);
2036
2354
  case 'URL':
2037
- return (<div>{label}<input id={id} type="url" required={field.isRequired} placeholder={field.placeholder} value={strVal} onChange={(e) => onChange(e.target.value)} />{help}</div>);
2355
+ 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>);
2038
2356
  case 'NUMBER':
2039
- 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>);
2357
+ 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>);
2040
2358
  case 'DATE':
2041
- return (<div>{label}<input id={id} type="date" required={field.isRequired} value={strVal} onChange={(e) => onChange(e.target.value)} />{help}</div>);
2359
+ 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>);
2042
2360
  case 'SELECT':
2043
- 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>);
2361
+ 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>);
2044
2362
  case 'MULTI_SELECT': {
2045
2363
  const arr = Array.isArray(value) ? value : [];
2046
- 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>);
2364
+ 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>);
2047
2365
  }
2048
2366
  case 'CHECKBOX':
2049
- 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>);
2367
+ 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>);
2050
2368
  case 'TEXT':
2051
2369
  default:
2052
- 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>);
2370
+ 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>);
2053
2371
  }
2054
2372
  }
2055
2373
 
2056
2374
  export function ContactPage() {
2057
2375
  const [schema, setSchema] = useState<ContactFormPublic | null>(null);
2058
2376
  const [values, setValues] = useState<Record<string, FieldValue>>({});
2377
+ const [errors, setErrors] = useState<Record<string, string>>({});
2059
2378
  const [honeypot, setHoneypot] = useState('');
2060
2379
  const [sent, setSent] = useState(false);
2061
2380
  const [loading, setLoading] = useState(false);
@@ -2079,6 +2398,19 @@ export function ContactPage() {
2079
2398
  if (loading) return;
2080
2399
  if (honeypot.trim().length > 0) { setSent(true); return; } // bot \u2192 silent success
2081
2400
 
2401
+ // \u2500\u2500 Client-side required-field validation \u2500\u2500
2402
+ const newErrors: Record<string, string> = {};
2403
+ for (const f of schema.fields) {
2404
+ if (f.isRequired && isEmpty(values[f.key] ?? defaultValueFor(f))) {
2405
+ newErrors[f.key] = \`\${f.label} is required\`; // or pull from your i18n system
2406
+ }
2407
+ }
2408
+ if (Object.keys(newErrors).length > 0) {
2409
+ setErrors(newErrors);
2410
+ return; // block submission
2411
+ }
2412
+ setErrors({});
2413
+
2082
2414
  setLoading(true);
2083
2415
  try {
2084
2416
  const payload: Record<string, unknown> = {};
@@ -2111,11 +2443,15 @@ export function ContactPage() {
2111
2443
  value={honeypot} onChange={(e) => setHoneypot(e.target.value)} />
2112
2444
  </div>
2113
2445
 
2114
- {schema.fields.map((field) => (
2115
- <DynamicField key={field.key} field={field}
2116
- value={values[field.key] ?? defaultValueFor(field)}
2117
- onChange={(v) => setValues((p) => ({ ...p, [field.key]: v }))} />
2118
- ))}
2446
+ {/* CSS grid \u2014 6 columns on sm+, 1 column on mobile */}
2447
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(6, 1fr)', gap: '1rem' }}>
2448
+ {schema.fields.map((field) => (
2449
+ <DynamicField key={field.key} field={field}
2450
+ value={values[field.key] ?? defaultValueFor(field)}
2451
+ error={errors[field.key]}
2452
+ onChange={(v) => setValues((p) => ({ ...p, [field.key]: v }))} />
2453
+ ))}
2454
+ </div>
2119
2455
 
2120
2456
  <button type="submit" disabled={loading}>
2121
2457
  {loading ? '\u2026' : schema.submitButton}
@@ -2125,13 +2461,27 @@ export function ContactPage() {
2125
2461
  }
2126
2462
  \`\`\`
2127
2463
 
2464
+ CSS for the grid column helpers (or use the equivalent Tailwind / inline styles):
2465
+
2466
+ \`\`\`css
2467
+ .grid-col-full { grid-column: span 6; }
2468
+ .grid-col-half { grid-column: span 6; }
2469
+ .grid-col-third { grid-column: span 6; }
2470
+
2471
+ @media (min-width: 640px) {
2472
+ .grid-col-half { grid-column: span 3; }
2473
+ .grid-col-third { grid-column: span 2; }
2474
+ }
2475
+ \`\`\`
2476
+
2128
2477
  ### Rules
2129
2478
 
2130
2479
  - **Rate limit:** 3 submissions / 60s per IP \u2014 show a friendly "try again later" message on HTTP 429.
2131
2480
  - **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\`.
2132
2481
  - **Built-in keys:** \`name\`, \`email\`, \`phone\`, \`subject\`, \`message\` always exist on the default form; the legacy \`createInquiry\` shape keeps working forever.
2133
2482
  - **Max values:** each field value is capped at 10 000 chars server-side. Validate client-side before submitting using \`field.validation.maxLength\`.
2134
- - **Required fields:** respect \`field.isRequired\`. The server validates too, but show \`required\` on the input for browser-level UX.
2483
+ - **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.
2484
+ - **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\`.
2135
2485
  - **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\`.
2136
2486
  - **Enum values:** SELECT and MULTI_SELECT always return \`enumValues\` (non-empty array). Render from \`enumValues\`, never a hardcoded list.
2137
2487
  - **Visibility:** the server strips fields with \`isVisible=false\`, so \`schema.fields\` only contains things to render.
@@ -2163,6 +2513,8 @@ function getSectionByTopic(topic, connectionId, currency) {
2163
2513
  return getCheckoutCustomFieldsSection();
2164
2514
  case "payment":
2165
2515
  return getPaymentProvidersSection();
2516
+ case "saved-payment-methods":
2517
+ return getSavedPaymentMethodsSection();
2166
2518
  case "auth":
2167
2519
  return getCustomerAuthSection();
2168
2520
  case "order-confirmation":
@@ -2175,6 +2527,8 @@ function getSectionByTopic(topic, connectionId, currency) {
2175
2527
  return getRecommendationsSection();
2176
2528
  case "product-customization-fields":
2177
2529
  return getProductCustomizationFieldsSection();
2530
+ case "modifier-groups":
2531
+ return getModifierGroupsSection();
2178
2532
  case "tax":
2179
2533
  return getTaxDisplaySection(cur);
2180
2534
  case "i18n":
@@ -2255,6 +2609,10 @@ function getSectionByTopic(topic, connectionId, currency) {
2255
2609
  "",
2256
2610
  "---",
2257
2611
  "",
2612
+ getModifierGroupsSection(),
2613
+ "",
2614
+ "---",
2615
+ "",
2258
2616
  getTaxDisplaySection(cur),
2259
2617
  "",
2260
2618
  "---",
@@ -2299,11 +2657,19 @@ var GET_SDK_DOCS_SCHEMA = {
2299
2657
  "inquiries",
2300
2658
  "all"
2301
2659
  ]).describe("The SDK documentation topic to retrieve"),
2302
- connectionId: import_zod.z.string().optional().describe("Vibe-coded connection ID (starts with vc_). Used to personalize setup code."),
2660
+ salesChannelId: import_zod.z.string().optional().describe("Sales channel ID (starts with vc_). Used to personalize setup code."),
2661
+ /** @deprecated alias of salesChannelId */
2662
+ connectionId: import_zod.z.string().optional().describe("Deprecated alias of salesChannelId \u2014 kept for backwards compat"),
2303
2663
  currency: import_zod.z.string().optional().describe("Store currency code (e.g., USD, ILS, EUR). Used in price formatting examples.")
2304
2664
  };
2305
2665
  async function handleGetSdkDocs(args) {
2306
- const content = getSectionByTopic(args.topic, args.connectionId, args.currency);
2666
+ const id = args.salesChannelId ?? args.connectionId;
2667
+ if (!args.salesChannelId && args.connectionId) {
2668
+ console.warn(
2669
+ "get-sdk-docs: `connectionId` is deprecated \u2014 use `salesChannelId` instead"
2670
+ );
2671
+ }
2672
+ const content = getSectionByTopic(args.topic, id, args.currency);
2307
2673
  return {
2308
2674
  content: [{ type: "text", text: content }]
2309
2675
  };
@@ -2325,6 +2691,9 @@ interface Product {
2325
2691
  basePrice: string; // Use parseFloat() for calculations
2326
2692
  salePrice?: string | null;
2327
2693
  costPrice?: string | null;
2694
+ priceMin?: string | null; // Lowest variant price (VARIABLE products). Use for catalog/JSON-LD range display.
2695
+ priceMax?: string | null; // Highest variant price (VARIABLE products).
2696
+ priceVaries?: boolean; // true when variant prices differ \u2014 show "\u20AA49 \u2013 \u20AA199" range.
2328
2697
  status: string;
2329
2698
  type: 'SIMPLE' | 'VARIABLE';
2330
2699
  isDownloadable?: boolean;
@@ -2342,7 +2711,7 @@ interface Product {
2342
2711
  attributeId: string;
2343
2712
  attributeOptionId: string;
2344
2713
  platform: string;
2345
- attribute: { id: string; name: string; displayType?: 'DEFAULT' | 'COLOR_SWATCH' | 'IMAGE_SWATCH' } | null;
2714
+ attribute: { id: string; name: string; displayType?: 'DEFAULT' | 'COLOR_SWATCH' | 'IMAGE_SWATCH' | 'MIXED_SWATCH' } | null;
2346
2715
  attributeOption: { id: string; name: string; value?: string | null; swatchColor?: string | null; swatchColor2?: string | null; swatchImageUrl?: string | null } | null;
2347
2716
  }>;
2348
2717
  createdAt: string;
@@ -2351,6 +2720,8 @@ interface Product {
2351
2720
 
2352
2721
  // Use getProductSwatches(product) to get grouped swatch data for storefront rendering.
2353
2722
  // Returns Array<{ attributeName, displayType, options: Array<{ name, swatchColor?, swatchColor2?, swatchImageUrl? }> }>
2723
+ // displayType rendering: COLOR_SWATCH \u2192 color circle (swatchColor/swatchColor2); IMAGE_SWATCH \u2192 image thumbnail (swatchImageUrl);
2724
+ // MIXED_SWATCH \u2192 per-option: render swatchImageUrl if set, else swatchColor if set, else text only.
2354
2725
 
2355
2726
  interface ProductImage {
2356
2727
  url: string;
@@ -2431,6 +2802,10 @@ interface ProductQueryParams {
2431
2802
  tags?: string | string[];
2432
2803
  minPrice?: number;
2433
2804
  maxPrice?: number;
2805
+ // Filter by custom-field (metafield) values. Keys = definition.key,
2806
+ // values = accepted values. Only definitions with filterable=true and
2807
+ // type SELECT/MULTI_SELECT/BOOLEAN are honored. AND across keys, OR within.
2808
+ metafields?: Record<string, string | string[]>;
2434
2809
  sortBy?: 'name' | 'price' | 'createdAt';
2435
2810
  sortOrder?: 'asc' | 'desc';
2436
2811
  }
@@ -2848,6 +3223,36 @@ interface PaymentStatus {
2848
3223
  error?: string;
2849
3224
  }
2850
3225
 
3226
+ // ---- Saved Payment Methods (vaulted cards) ----
3227
+ // Display-only summary of a customer's vaulted payment method. The
3228
+ // underlying provider token is encrypted at rest in the platform DB
3229
+ // and NEVER returned through the SDK.
3230
+ //
3231
+ // To opt into vaulting at checkout, pass saveCard: true to
3232
+ // createPaymentIntent (only honored for logged-in customers).
3233
+ //
3234
+ // To list / remove saved methods, see:
3235
+ // client.listSavedPaymentMethods(storeId, customerId)
3236
+ // client.removeSavedPaymentMethod(storeId, customerId, methodId)
3237
+
3238
+ interface SavedPaymentMethodSummary {
3239
+ id: string;
3240
+ customerId: string;
3241
+ appInstallationId: string;
3242
+ paymentMethod: string; // 'credit_card' | 'paypal' | 'bank_account'
3243
+ brand: string | null;
3244
+ last4: string | null;
3245
+ expMonth: number | null;
3246
+ expYear: number | null;
3247
+ isDefault: boolean;
3248
+ status: string; // 'active' | 'expired' | 'invalid'
3249
+ failureReason: string | null;
3250
+ lastUsedAt: string | null;
3251
+ expiresAt: string | null;
3252
+ createdAt: string;
3253
+ updatedAt: string;
3254
+ }
3255
+
2851
3256
  interface WaitForOrderResult {
2852
3257
  success: boolean;
2853
3258
  status: PaymentStatus; // Access: result.status.orderNumber
@@ -2859,9 +3264,9 @@ var HELPERS_TYPES = `// ---- Helper Functions (import from 'brainerce') ----
2859
3264
  // Price helpers
2860
3265
  function formatPrice(priceString: string | number | undefined | null, options?: { currency?: string; locale?: string; }): string;
2861
3266
  function getProductPrice(product: Pick<Product, 'basePrice' | 'salePrice'>): number;
2862
- function getProductPriceInfo(product: Pick<Product, 'basePrice' | 'salePrice'>): {
3267
+ function getProductPriceInfo(product: Pick<Product, 'basePrice' | 'salePrice' | 'discount' | 'priceMin' | 'priceVaries'>): {
2863
3268
  price: number; originalPrice: number; isOnSale: boolean; discountAmount: number; discountPercent: number;
2864
- };
3269
+ }; // Falls back to priceMin when basePrice=0 (VARIABLE products)
2865
3270
  function getVariantPrice(variant: Pick<ProductVariant, 'price' | 'salePrice'>, productBasePrice: string): number;
2866
3271
 
2867
3272
  // Cart helpers
@@ -2947,15 +3352,32 @@ interface CartUpgradeSuggestion {
2947
3352
  priceDelta: string;
2948
3353
  deltaPercent: number;
2949
3354
  }
2950
- interface CartBundleOffer {
3355
+ interface CartBundleOfferOfferedProduct {
2951
3356
  id: string;
2952
- bundleProduct: ProductRecommendation;
3357
+ name: string;
3358
+ slug: string | null;
3359
+ basePrice: string;
3360
+ salePrice: string | null;
3361
+ images: Array<{ url: string }>;
3362
+ type: string;
2953
3363
  originalPrice: string;
2954
3364
  discountedPrice: string;
3365
+ }
3366
+ interface CartBundleOffer {
3367
+ id: string;
3368
+ name: string;
3369
+ description: string | null;
3370
+ // productIds[0] = trigger product (must be in cart for the bundle to surface);
3371
+ // productIds[1..] = offered together at the bundle discount.
3372
+ triggerProductId: string;
3373
+ productIds: string[];
3374
+ // offeredProducts = productIds[1..] minus those already in cart, each with
3375
+ // its own original/discounted price applied.
3376
+ offeredProducts: CartBundleOfferOfferedProduct[];
2955
3377
  discountType: 'PERCENTAGE' | 'FIXED_AMOUNT';
2956
- discountValue: number;
2957
- requiresVariantSelection: boolean;
2958
- lockedVariant?: { id: string; name?: string };
3378
+ discountValue: string;
3379
+ totalOriginalPrice: string;
3380
+ totalDiscountedPrice: string;
2959
3381
  }`;
2960
3382
  var INQUIRIES_TYPES = `// ---- Contact Inquiries & Forms ----
2961
3383
 
@@ -3010,6 +3432,7 @@ interface ContactFormPublicField {
3010
3432
  enumValues?: { value: string; label: string }[]; // present (non-empty) for SELECT / MULTI_SELECT
3011
3433
  validation?: ContactFormFieldValidation;
3012
3434
  defaultValue?: string;
3435
+ width?: 'FULL' | 'HALF' | 'THIRD'; // layout hint \u2014 FULL = full row, HALF = half row, THIRD = one-third row
3013
3436
  }
3014
3437
 
3015
3438
  interface ContactFormPublic {
@@ -3041,6 +3464,19 @@ interface ContactFormSummary {
3041
3464
  // MULTI_SELECT \u2192 multiple <input type="checkbox">, value is string[]
3042
3465
  // CHECKBOX \u2192 single <input type="checkbox">, value is boolean
3043
3466
  // ------------------------------------------------------------------
3467
+ //
3468
+ // Layout: render fields inside a CSS grid container.
3469
+ // width='FULL' (default) \u2192 span entire row
3470
+ // width='HALF' \u2192 span half the row (two HALF fields sit side-by-side)
3471
+ // width='THIRD' \u2192 span one-third of the row (three THIRD fields sit side-by-side)
3472
+ // Use a 6-column grid for clean divisibility:
3473
+ // FULL \u2192 col-span-6 | HALF \u2192 col-span-3 | THIRD \u2192 col-span-2
3474
+ // Stack to full width on small screens (< sm breakpoint).
3475
+ //
3476
+ // Required fields: show a red asterisk (*) next to the label, validate
3477
+ // client-side before submission, and display inline error messages for
3478
+ // any empty required field. Do NOT rely only on the server returning 400.
3479
+ // ------------------------------------------------------------------
3044
3480
 
3045
3481
  // SDK methods
3046
3482
  // await brainerce.createInquiry(input) \u2192 POST /stores/{storeId}/inquiries
@@ -3053,6 +3489,130 @@ interface ContactFormSummary {
3053
3489
  // - Always pass \`locale\` \u2014 inbox filters inquiries by language, and schema labels come back translated
3054
3490
  // - Render \`schema.name\` / \`description\` / \`submitButton\` / \`successMessage\` directly \u2014 do NOT hardcode copy
3055
3491
  // - Unknown field keys are stripped server-side \u2014 safe to send extras during dev`;
3492
+ var MODIFIER_GROUPS_TYPES = `// ---- Modifier Groups (Restaurant / Build-Your-Own) ----
3493
+ // Modifier groups are merchant-defined option blocks attached to a product
3494
+ // (toppings, sauce, bread type, \u2026). They differ from product.customizationFields:
3495
+ // modifier groups are STRUCTURED priced choices validated server-side, while
3496
+ // customizationFields are arbitrary buyer input (text/photo/color).
3497
+ //
3498
+ // Money fields are decimal STRINGS on the wire \u2014 "5.00", "-2.00" (downsell).
3499
+ // Never JSON Number. Use parseFloat() only at display time. Do not compute
3500
+ // the line total client-side; the server runs free-allocation and returns
3501
+ // cart.items[i].unitPrice + .modifiers[] + .modifiersTotal.
3502
+
3503
+ export type ModifierSelectionType = 'SINGLE' | 'MULTIPLE';
3504
+
3505
+ export type FreeAllocationPolicy = 'EXPENSIVE_FREE' | 'CHEAPEST_FREE' | 'SELECTION_ORDER';
3506
+
3507
+ export interface Modifier {
3508
+ id: string;
3509
+ name: string;
3510
+ description?: string;
3511
+ /** Decimal string. Negative values = downsell modifiers ("-2.00"). */
3512
+ priceDelta: string;
3513
+ sku?: string;
3514
+ image?: { url: string; thumbnailUrl?: string; alt?: string };
3515
+ position: number;
3516
+ /** Pre-checked on first render. */
3517
+ isDefault: boolean;
3518
+ /** false = sold out \u2014 disable in UI with a "Sold out" badge. */
3519
+ available: boolean;
3520
+ /** When true, never applied as a free selection \u2014 always charges priceDelta even when freeQuantity > 0 on the group. */
3521
+ excludeFromFree?: boolean;
3522
+ /** Nested combo: opens a sub-flow; depth \u2264 3 enforced server-side. */
3523
+ referencedProductId?: string;
3524
+ translations?: Record<string, { name?: string; description?: string }>;
3525
+ }
3526
+
3527
+ export interface ModifierGroup {
3528
+ id: string;
3529
+ /**
3530
+ * Set when fetched in a product context (the ProductModifierGroup row id).
3531
+ * Use this to update or detach the attachment without reattaching the group.
3532
+ */
3533
+ attachmentId?: string;
3534
+ /** Customer-facing canonical name. */
3535
+ name: string;
3536
+ /**
3537
+ * Admin-only disambiguator \u2014 NEVER present in storefront responses.
3538
+ * If your client code reads this, you're hitting an admin endpoint by mistake.
3539
+ */
3540
+ internalName?: string;
3541
+ description?: string;
3542
+ selectionType: ModifierSelectionType;
3543
+ /** Effective minimum after any per-attach / per-variant overrides. */
3544
+ min: number;
3545
+ /** Effective maximum; null = unlimited. **0 = group hidden for this variant** (PRD \xA77.2.2). */
3546
+ max?: number | null;
3547
+ freeQuantity: number;
3548
+ required: boolean;
3549
+ freeAllocationPolicy: FreeAllocationPolicy;
3550
+ modifiers: Modifier[];
3551
+ /** Effective default selections (after attachment-level overrides). */
3552
+ defaultModifierIds: string[];
3553
+ translations?: Record<string, { name?: string; description?: string }>;
3554
+ }
3555
+
3556
+ /** Customer-side selection payload \u2014 modifierIds in click-order. */
3557
+ export interface ModifierSelection {
3558
+ modifierGroupId: string;
3559
+ modifierIds: string[];
3560
+ }
3561
+
3562
+ /** Per-line modifier breakdown surfaced on cart.items[i] / order.items[i]. */
3563
+ export interface CartItemModifierLine {
3564
+ modifierId: string;
3565
+ /** Snapshot of the modifier's name at the time the line was added. */
3566
+ name: string;
3567
+ /** Decimal string snapshot of the priceDelta at the time the line was added. */
3568
+ priceDelta: string;
3569
+ /** True if this modifier consumed one of the group's free slots. */
3570
+ freeApplied: boolean;
3571
+ }
3572
+
3573
+ /**
3574
+ * Stable error codes returned in the structured 400 envelope when a cart
3575
+ * payload fails server-side validation. The SDK exposes the envelope on
3576
+ * BrainerceError.details \u2014 switch on details.code === 'MODIFIER_VALIDATION_FAILED'
3577
+ * first, then iterate details.errors[].
3578
+ */
3579
+ export type ModifierValidationCode =
3580
+ | 'REQUIRED_GROUP_MISSING'
3581
+ | 'MIN_SELECTIONS_NOT_MET'
3582
+ | 'MAX_SELECTIONS_EXCEEDED'
3583
+ | 'SINGLE_GROUP_MULTIPLE_PICKS'
3584
+ | 'UNKNOWN_MODIFIER'
3585
+ | 'UNKNOWN_GROUP'
3586
+ | 'MODIFIER_DISABLED_FOR_VARIANT'
3587
+ | 'MODIFIER_NOT_AVAILABLE'
3588
+ | 'NESTED_DEPTH_EXCEEDED'
3589
+ | 'NESTED_REQUIRES_PRODUCT_REF'
3590
+ | 'INVALID_PRICE_DELTA'
3591
+ | 'MODIFIER_PRICE_FLOOR_VIOLATED';
3592
+
3593
+ export interface ModifierValidationError {
3594
+ code: ModifierValidationCode;
3595
+ message: string;
3596
+ modifierGroupId?: string;
3597
+ modifierId?: string;
3598
+ }
3599
+
3600
+ // Cart DTOs gain optional selections + nestedByModifierId \u2014 see CART_TYPES above.
3601
+ // Add to cart with selections:
3602
+ //
3603
+ // await client.smartAddToCart({
3604
+ // productId,
3605
+ // variantId,
3606
+ // quantity: 1,
3607
+ // selections: [
3608
+ // { modifierGroupId: 'mg_bread', modifierIds: ['m_thick'] },
3609
+ // { modifierGroupId: 'mg_toppings', modifierIds: ['m_olive', 'm_bacon'] },
3610
+ // ],
3611
+ // });
3612
+ //
3613
+ // The cart line then carries:
3614
+ // cart.items[i].modifiers \u2014 CartItemModifierLine[]
3615
+ // cart.items[i].modifiersTotal \u2014 decimal string of the paid (non-free) deltas`;
3056
3616
  var TYPES_BY_DOMAIN = {
3057
3617
  products: PRODUCTS_TYPES,
3058
3618
  cart: CART_TYPES,
@@ -3061,7 +3621,8 @@ var TYPES_BY_DOMAIN = {
3061
3621
  customers: CUSTOMERS_TYPES,
3062
3622
  payments: PAYMENTS_TYPES,
3063
3623
  helpers: HELPERS_TYPES,
3064
- inquiries: INQUIRIES_TYPES
3624
+ inquiries: INQUIRIES_TYPES,
3625
+ "modifier-groups": MODIFIER_GROUPS_TYPES
3065
3626
  };
3066
3627
  function getTypesByDomain(domain) {
3067
3628
  if (domain === "all") {
@@ -3127,7 +3688,12 @@ var GET_CODE_EXAMPLE_SCHEMA = {
3127
3688
  "reservation-countdown",
3128
3689
  "search-autocomplete-debounce",
3129
3690
  "i18n-set-locale",
3130
- "order-history-full"
3691
+ "order-history-full",
3692
+ "modifier-groups-render",
3693
+ "cart-add-with-selections",
3694
+ "modifier-validation-error-handling",
3695
+ "checkout-price-drift",
3696
+ "cart-item-modifier-display"
3131
3697
  ]).describe("The SDK operation to get a snippet for.")
3132
3698
  };
3133
3699
  var SNIPPETS = {
@@ -3135,7 +3701,10 @@ var SNIPPETS = {
3135
3701
  import { BrainerceClient } from 'brainerce';
3136
3702
 
3137
3703
  export const client = new BrainerceClient({
3138
- connectionId: process.env.BRAINERCE_CONNECTION_ID!, // vc_*
3704
+ // Read either env var name (the new one is preferred; the old one is a soft
3705
+ // alias kept for backwards compatibility \u2014 both are accepted by the SDK).
3706
+ salesChannelId:
3707
+ process.env.BRAINERCE_SALES_CHANNEL_ID! ?? process.env.BRAINERCE_CONNECTION_ID!, // vc_*
3139
3708
  });
3140
3709
 
3141
3710
  // If the store has i18n enabled, set the locale at app start based on
@@ -3199,7 +3768,7 @@ function selectVariant(product: Product, selections: Record<string, string>) {
3199
3768
  import { client } from './brainerce';
3200
3769
 
3201
3770
  // 1. Submit the address + email. Email is REQUIRED on the DTO.
3202
- const { shippingRates } = await client.setShippingAddress(checkoutId, {
3771
+ const { checkout, rates } = await client.setShippingAddress(checkoutId, {
3203
3772
  email,
3204
3773
  firstName,
3205
3774
  lastName,
@@ -3210,10 +3779,11 @@ const { shippingRates } = await client.setShippingAddress(checkoutId, {
3210
3779
  postalCode,
3211
3780
  country,
3212
3781
  });
3782
+ // rates = available shipping rates; checkout = updated checkout object
3213
3783
 
3214
3784
  // 2. Let the customer pick one of the returned rates.
3215
- const chosen = shippingRates[0]; // user selection
3216
- await client.setShippingMethod(checkoutId, chosen.id);
3785
+ const chosen = rates[0]; // user selection
3786
+ await client.selectShippingMethod(checkoutId, chosen.id);
3217
3787
 
3218
3788
  // 3. Re-fetch the checkout to get updated totals (tax may change after address).
3219
3789
  const checkout = await client.getCheckout(checkoutId);
@@ -3267,21 +3837,21 @@ if (providers.length === 0) {
3267
3837
  }
3268
3838
 
3269
3839
  const active = providers[0];`,
3270
- "checkout-stripe-confirm": `// Stripe confirm flow. Requires @stripe/stripe-js and @stripe/react-stripe-js
3271
- // (or the vanilla Stripe.js browser SDK).
3840
+ "checkout-stripe-confirm": `// Stripe confirm flow. Requires @stripe/stripe-js.
3272
3841
  import { loadStripe } from '@stripe/stripe-js';
3273
3842
  import { client } from './brainerce';
3274
3843
 
3275
3844
  const stripe = await loadStripe(stripePublicKey);
3276
3845
  if (!stripe) throw new Error('Stripe failed to load');
3277
3846
 
3278
- // The SDK returns a clientSecret via the provider's config after setPaymentMethod.
3279
- const { clientSecret } = await client.setPaymentMethod(checkoutId, {
3280
- providerId: stripeProvider.id,
3847
+ // Create a payment intent \u2014 returns clientSecret for Stripe Elements.
3848
+ const intent = await client.createPaymentIntent(checkoutId, {
3849
+ successUrl: \`\${window.location.origin}/order-confirmation?checkoutId=\${checkoutId}\`,
3850
+ cancelUrl: \`\${window.location.origin}/checkout?error=cancelled\`,
3281
3851
  });
3282
3852
 
3283
- // Use Stripe Elements to collect the card, then confirm:
3284
- const result = await stripe.confirmCardPayment(clientSecret, {
3853
+ // Use Stripe Elements to confirm:
3854
+ const result = await stripe.confirmCardPayment(intent.clientSecret, {
3285
3855
  payment_method: { card: cardElement },
3286
3856
  });
3287
3857
 
@@ -3290,21 +3860,21 @@ if (result.error) {
3290
3860
  return;
3291
3861
  }
3292
3862
 
3293
- // Success \u2014 redirect to your confirmation page with checkoutId in the URL.
3863
+ // Success \u2014 redirect to confirmation page.
3294
3864
  // The confirmation page runs handlePaymentSuccess + waitForOrder.
3295
3865
  window.location.assign(\`/order-confirmation?checkoutId=\${checkoutId}\`);`,
3296
3866
  "checkout-paypal-confirm": `// PayPal confirm flow. Use the PayPal JS SDK (render a PayPal button component).
3297
3867
  import { client } from './brainerce';
3298
3868
 
3299
- // When the PayPal button's onApprove fires with the PayPal order ID:
3300
- async function onPayPalApprove(paypalOrderId: string) {
3301
- await client.setPaymentMethod(checkoutId, {
3302
- providerId: paypalProvider.id,
3303
- providerData: { paypalOrderId },
3304
- });
3869
+ // Create a payment intent first \u2014 for PayPal this returns the PayPal order ID.
3870
+ const intent = await client.createPaymentIntent(checkoutId, {
3871
+ successUrl: \`\${window.location.origin}/order-confirmation?checkoutId=\${checkoutId}\`,
3872
+ cancelUrl: \`\${window.location.origin}/checkout?error=cancelled\`,
3873
+ });
3305
3874
 
3306
- // PayPal confirmation \u2014 the SDK captures the order server-side.
3307
- // After this resolves, redirect to confirmation.
3875
+ // Render a PayPal button using intent.clientSdk (provider SDK config).
3876
+ // When the PayPal button's onApprove fires, redirect to confirmation:
3877
+ function onPayPalApprove() {
3308
3878
  window.location.assign(\`/order-confirmation?checkoutId=\${checkoutId}\`);
3309
3879
  }`,
3310
3880
  "checkout-sandbox-confirm": `// Sandbox payment \u2014 store has sandboxPaymentsEnabled=true. No real charge.
@@ -3347,7 +3917,7 @@ async function registerAndMaybeVerify(input: {
3347
3917
  firstName: string;
3348
3918
  lastName: string;
3349
3919
  }) {
3350
- const result = await client.register(input);
3920
+ const result = await client.registerCustomer(input);
3351
3921
  if (result.requiresVerification) {
3352
3922
  // Route the user to your verify-email UI. Do NOT treat them as logged in.
3353
3923
  return { state: 'needs-verification' as const };
@@ -3368,7 +3938,7 @@ async function resendCode() {
3368
3938
  import { client } from './brainerce';
3369
3939
 
3370
3940
  async function login(email: string, password: string) {
3371
- const result = await client.login({ email, password });
3941
+ const result = await client.loginCustomer(email, password);
3372
3942
  if (result.requiresVerification) {
3373
3943
  return { state: 'needs-verification' as const };
3374
3944
  }
@@ -3403,25 +3973,37 @@ async function submitReset(token: string, newPassword: string) {
3403
3973
  "oauth-redirect-and-callback": `// OAuth sign-in. Redirect flow, NOT popup.
3404
3974
  import { client } from './brainerce';
3405
3975
 
3406
- // On your login / register UI:
3407
- const providers = await client.getAvailableOAuthProviders();
3408
- // Render one button per provider. On click, redirect the browser:
3409
- for (const p of providers) {
3410
- // button.onclick = () => window.location.assign(p.authorizationUrl)
3976
+ // Step 1: Get available provider names (strings: 'GOOGLE', 'FACEBOOK', 'GITHUB')
3977
+ const { providers } = await client.getAvailableOAuthProviders();
3978
+
3979
+ // Step 2: For each provider, fetch the authorization URL, then redirect.
3980
+ // Call this when the user clicks a provider button:
3981
+ async function redirectToOAuth(provider: string) {
3982
+ const { authorizationUrl } = await client.getOAuthAuthorizeUrl(provider, {
3983
+ redirectUrl: \`\${window.location.origin}/auth/callback\`,
3984
+ });
3985
+ window.location.href = authorizationUrl; // full-page redirect, NOT a popup
3411
3986
  }
3412
3987
 
3413
- // On your OAuth callback route (SERVER-SIDE / BFF):
3414
- // 1. Read token + oauth_success (or oauth_error) from query params.
3415
- // 2. If oauth_error: redirect to login with the error message.
3416
- // 3. If oauth_success: exchange the token for a session cookie (your BFF),
3417
- // then redirect the browser to /account or wherever.
3418
- // Do NOT allow the token to land in browser history as a query param.`,
3988
+ // Step 3: On your /auth/callback route, read token from URL params:
3989
+ const params = new URLSearchParams(window.location.search);
3990
+ const oauthError = params.get('oauth_error');
3991
+ const token = params.get('token');
3992
+ if (oauthError) {
3993
+ window.location.href = '/login?error=' + encodeURIComponent(oauthError);
3994
+ } else if (token) {
3995
+ client.setCustomerToken(token);
3996
+ window.location.href = '/account';
3997
+ }`,
3419
3998
  "coupon-apply-and-remove": `// Coupon input. Build the UI even if no coupons exist today \u2014 it auto-hides.
3999
+ // IMPORTANT: use applyCheckoutCoupon when a checkoutId is available (checkout page).
4000
+ // Using applyCoupon after checkout creation does NOT update the checkout total.
3420
4001
  import { client } from './brainerce';
3421
4002
 
3422
- async function applyCoupon(code: string) {
4003
+ // On cart page (no checkout session yet)
4004
+ async function applyCouponToCart(cartId: string, code: string) {
3423
4005
  try {
3424
- const cart = await client.applyCoupon(code);
4006
+ const cart = await client.applyCoupon(cartId, code);
3425
4007
  return { ok: true, cart };
3426
4008
  } catch (err) {
3427
4009
  // Invalid / expired / minimum not met. Surface the specific error.
@@ -3429,17 +4011,30 @@ async function applyCoupon(code: string) {
3429
4011
  }
3430
4012
  }
3431
4013
 
3432
- async function removeCoupon() {
3433
- const cart = await client.removeCoupon();
3434
- return cart;
4014
+ async function removeCouponFromCart(cartId: string) {
4015
+ return client.removeCoupon(cartId);
4016
+ }
4017
+
4018
+ // On checkout page (checkout session already exists \u2014 always prefer this)
4019
+ async function applyCouponToCheckout(checkoutId: string, code: string) {
4020
+ try {
4021
+ const checkout = await client.applyCheckoutCoupon(checkoutId, code);
4022
+ return { ok: true, checkout }; // checkout.total is already updated
4023
+ } catch (err) {
4024
+ return { ok: false, error: (err as Error).message };
4025
+ }
4026
+ }
4027
+
4028
+ async function removeCouponFromCheckout(checkoutId: string) {
4029
+ return client.removeCheckoutCoupon(checkoutId);
3435
4030
  }`,
3436
4031
  "reservation-countdown": `// Reservation countdown. Use the SDK's expiry timestamp \u2014 do NOT invent
3437
4032
  // your own timer state.
3438
4033
  import { client } from './brainerce';
3439
4034
 
3440
- function getRemainingMs(cart: { reservationExpiresAt?: string | null }): number {
3441
- if (!cart.reservationExpiresAt) return 0;
3442
- const expiry = new Date(cart.reservationExpiresAt).getTime();
4035
+ function getRemainingMs(cart: { reservation?: { expiresAt?: string } }): number {
4036
+ if (!cart.reservation?.expiresAt) return 0;
4037
+ const expiry = new Date(cart.reservation.expiresAt).getTime();
3443
4038
  return Math.max(0, expiry - Date.now());
3444
4039
  }
3445
4040
 
@@ -3466,8 +4061,8 @@ function onSearchChange(query: string, onResults: (items: unknown[]) => void) {
3466
4061
  return;
3467
4062
  }
3468
4063
  searchTimer = setTimeout(async () => {
3469
- const results = await client.searchProducts(query);
3470
- onResults(results);
4064
+ const { products } = await client.getSearchSuggestions(query);
4065
+ onResults(products);
3471
4066
  }, 300);
3472
4067
  }`,
3473
4068
  "i18n-set-locale": `// i18n \u2014 only if get-store-capabilities reports i18n.enabled.
@@ -3555,7 +4150,412 @@ for (const order of orders) {
3555
4150
 
3556
4151
  // All sections above are conditional. Absent data = section not rendered \u2014
3557
4152
  // never show empty placeholders. The create-brainerce-store template's
3558
- // src/components/account/order-history.tsx is the reference implementation.`
4153
+ // src/components/account/order-history.tsx is the reference implementation.`,
4154
+ "modifier-groups-render": `// Render modifier groups on the product detail page (toppings / sauce /
4155
+ // build-your-own). The data lives on product.modifierGroups \u2014 only present
4156
+ // for restaurant / customizable products.
4157
+ import { useState } from 'react';
4158
+ import type { ModifierGroup, Product } from 'brainerce';
4159
+
4160
+ function ModifierGroups({ product }: { product: Product }) {
4161
+ const groups: ModifierGroup[] = product.modifierGroups ?? [];
4162
+ if (groups.length === 0) return null; // not a customizable product
4163
+
4164
+ // Local state: selected modifier IDs per group, in click-order.
4165
+ const [selections, setSelections] = useState<Record<string, string[]>>(() =>
4166
+ buildInitialSelections(groups)
4167
+ );
4168
+
4169
+ return (
4170
+ <>
4171
+ {groups.map((group) => {
4172
+ // PRD \xA77.2.2: max === 0 means "hidden for this variant" \u2014 skip entirely.
4173
+ if (group.max === 0) return null;
4174
+
4175
+ const isSingle = group.selectionType === 'SINGLE';
4176
+ const inputType = isSingle ? 'radio' : 'checkbox';
4177
+ const sorted = [...group.modifiers].sort((a, b) => a.position - b.position);
4178
+ const picks = selections[group.id] ?? [];
4179
+ const usedFree = Math.min(picks.length, group.freeQuantity);
4180
+
4181
+ const toggle = (modifierId: string, checked: boolean) => {
4182
+ setSelections((prev) => {
4183
+ const cur = prev[group.id] ?? [];
4184
+ if (isSingle) return { ...prev, [group.id]: checked ? [modifierId] : [] };
4185
+ if (checked) {
4186
+ if (group.max != null && cur.length >= group.max) return prev; // at cap
4187
+ return { ...prev, [group.id]: [...cur, modifierId] };
4188
+ }
4189
+ return { ...prev, [group.id]: cur.filter((id) => id !== modifierId) };
4190
+ });
4191
+ };
4192
+
4193
+ return (
4194
+ <fieldset key={group.id}>
4195
+ <legend>
4196
+ {group.name}{group.required && ' *'}
4197
+ </legend>
4198
+ {group.freeQuantity > 0 && (
4199
+ <p>{usedFree} of {group.freeQuantity} free</p>
4200
+ )}
4201
+ {sorted.map((m) => (
4202
+ <label key={m.id}>
4203
+ <input
4204
+ type={inputType}
4205
+ name={\`mg-\${group.id}\`}
4206
+ value={m.id}
4207
+ checked={picks.includes(m.id)}
4208
+ disabled={!m.available}
4209
+ onChange={(e) => toggle(m.id, e.target.checked)}
4210
+ />
4211
+ {m.name}
4212
+ {parseFloat(m.priceDelta) !== 0 && (
4213
+ <span> {parseFloat(m.priceDelta) > 0 ? '+' : ''}{m.priceDelta}</span>
4214
+ )}
4215
+ {!m.available && <span> (Sold out)</span>}
4216
+ </label>
4217
+ ))}
4218
+ </fieldset>
4219
+ );
4220
+ })}
4221
+ </>
4222
+ );
4223
+ }
4224
+
4225
+ // Initial state: prefer per-attach defaultModifierIds; fall back to
4226
+ // modifier.isDefault flags. Filter sold-out modifiers.
4227
+ function buildInitialSelections(groups: ModifierGroup[]): Record<string, string[]> {
4228
+ const out: Record<string, string[]> = {};
4229
+ for (const group of groups) {
4230
+ if (group.max === 0) continue;
4231
+ const fromAttach = group.defaultModifierIds ?? [];
4232
+ const fromIsDefault = group.modifiers
4233
+ .filter((m) => m.isDefault && m.available)
4234
+ .map((m) => m.id);
4235
+ const merged =
4236
+ fromAttach.length > 0
4237
+ ? fromAttach.filter((id) => group.modifiers.some((m) => m.id === id && m.available))
4238
+ : fromIsDefault;
4239
+ const capped = group.selectionType === 'SINGLE' ? merged.slice(0, 1) : merged;
4240
+ if (capped.length > 0) out[group.id] = capped;
4241
+ }
4242
+ return out;
4243
+ }
4244
+
4245
+ // PRICE-DELTA RULES:
4246
+ // - "5.00" \u2192 +$5 added to unit price
4247
+ // - "-2.00" \u2192 downsell: subtracts $2; never consumes a free slot
4248
+ // - "0.00" \u2192 free option; render no price label
4249
+ // Server enforces unitPrice >= 0; rejects negative deltas combined with
4250
+ // referencedProductId. Do NOT compute the line total client-side \u2014 the
4251
+ // server runs free-allocation and returns cart.items[i].unitPrice.
4252
+
4253
+ `,
4254
+ "cart-add-with-selections": `// Pass modifier-group selections on add-to-cart. The server validates
4255
+ // against the effective rules and computes the final unitPrice (base +
4256
+ // paid modifiers, after the free-allocation policy runs).
4257
+ import { client } from './brainerce';
4258
+ import type { ModifierSelection } from 'brainerce';
4259
+
4260
+ async function addPizzaToCart(
4261
+ productId: string,
4262
+ variantId: string,
4263
+ selectionsByGroup: Record<string, string[]>
4264
+ ) {
4265
+ // Adapter: shape used in component state \u2192 wire format the SDK accepts.
4266
+ const selections: ModifierSelection[] = Object.entries(selectionsByGroup)
4267
+ .filter(([, modifierIds]) => modifierIds.length > 0)
4268
+ .map(([modifierGroupId, modifierIds]) => ({ modifierGroupId, modifierIds }));
4269
+
4270
+ // modifierIds is in CLICK-ORDER \u2014 used by the SELECTION_ORDER free-allocation
4271
+ // policy. Don't sort it; preserve the order the customer clicked.
4272
+
4273
+ const cart = await client.smartAddToCart({
4274
+ productId,
4275
+ variantId,
4276
+ quantity: 1,
4277
+ selections,
4278
+ });
4279
+
4280
+ // Read the snapshot back off the new cart line.
4281
+ const line = cart.items[cart.items.length - 1];
4282
+ // line.modifiers \u2192 CartItemModifierLine[] with { modifierId, name, priceDelta, freeApplied }
4283
+ // line.modifiersTotal \u2192 decimal string of paid (non-free) deltas
4284
+ // line.unitPrice \u2192 final unit price including all paid modifiers
4285
+ console.log('Free applied:', line.modifiers?.filter((m) => m.freeApplied).map((m) => m.name));
4286
+ }
4287
+
4288
+ // EDITING SELECTIONS LATER (PRD \xA77.2.3 \u2014 idempotent replacement):
4289
+ // PATCH the cart item with a fresh selections array. The server deletes ALL
4290
+ // existing CartItemModifier rows and recreates them in the same transaction.
4291
+ // To leave selections untouched (e.g., quantity-only update), omit them.
4292
+ async function changeToppings(cartId: string, itemId: string, newToppingIds: string[]) {
4293
+ await client.updateCartItem(cartId, itemId, {
4294
+ quantity: 1,
4295
+ selections: [{ modifierGroupId: 'mg_toppings', modifierIds: newToppingIds }],
4296
+ });
4297
+ }
4298
+
4299
+ // NESTED COMBOS (depth \u2264 3): when a picked modifier carries
4300
+ // referencedProductId, fetch that product's own modifierGroups, collect the
4301
+ // nested selections, and pass them keyed by the PARENT modifier id.
4302
+ async function addCombo(productId: string, mainBurger: string) {
4303
+ await client.smartAddToCart({
4304
+ productId,
4305
+ quantity: 1,
4306
+ selections: [
4307
+ { modifierGroupId: 'mg_main', modifierIds: [mainBurger] }, // the burger
4308
+ { modifierGroupId: 'mg_drink', modifierIds: ['m_cola'] },
4309
+ ],
4310
+ nestedByModifierId: {
4311
+ [mainBurger]: [
4312
+ { modifierGroupId: 'mg_doneness', modifierIds: ['m_medium'] },
4313
+ { modifierGroupId: 'mg_cheese', modifierIds: ['m_cheddar'] },
4314
+ ],
4315
+ },
4316
+ });
4317
+ }`,
4318
+ "modifier-validation-error-handling": `// Surface MODIFIER_VALIDATION_FAILED to the user. The SDK throws
4319
+ // BrainerceError on HTTP 400; the structured envelope is on .details.
4320
+ import { client } from './brainerce';
4321
+ import type { ModifierSelection } from 'brainerce';
4322
+
4323
+ interface ModifierValidationError {
4324
+ code:
4325
+ | 'REQUIRED_GROUP_MISSING'
4326
+ | 'MIN_SELECTIONS_NOT_MET'
4327
+ | 'MAX_SELECTIONS_EXCEEDED'
4328
+ | 'SINGLE_GROUP_MULTIPLE_PICKS'
4329
+ | 'UNKNOWN_MODIFIER'
4330
+ | 'UNKNOWN_GROUP'
4331
+ | 'MODIFIER_DISABLED_FOR_VARIANT'
4332
+ | 'MODIFIER_NOT_AVAILABLE'
4333
+ | 'NESTED_DEPTH_EXCEEDED'
4334
+ | 'NESTED_REQUIRES_PRODUCT_REF'
4335
+ | 'INVALID_PRICE_DELTA'
4336
+ | 'MODIFIER_PRICE_FLOOR_VIOLATED';
4337
+ message: string;
4338
+ modifierGroupId?: string;
4339
+ modifierId?: string;
4340
+ }
4341
+
4342
+ async function tryAddToCart(productId: string, selections: ModifierSelection[]) {
4343
+ try {
4344
+ await client.smartAddToCart({ productId, quantity: 1, selections });
4345
+ return { ok: true as const };
4346
+ } catch (err) {
4347
+ const e = err as {
4348
+ statusCode?: number;
4349
+ details?: { code?: string; errors?: ModifierValidationError[] };
4350
+ };
4351
+
4352
+ if (e.statusCode === 400 && e.details?.code === 'MODIFIER_VALIDATION_FAILED') {
4353
+ // Surface each issue inline. groupId / modifierId let you highlight the
4354
+ // specific control the customer needs to fix.
4355
+ const issues = e.details.errors ?? [];
4356
+ const messages = issues.map((i) => formatIssue(i));
4357
+ return { ok: false as const, issues, messages };
4358
+ }
4359
+
4360
+ // Some other error (network, 500, etc.) \u2014 re-throw to a generic handler.
4361
+ throw err;
4362
+ }
4363
+ }
4364
+
4365
+ function formatIssue(issue: ModifierValidationError): string {
4366
+ switch (issue.code) {
4367
+ case 'REQUIRED_GROUP_MISSING':
4368
+ return 'Please pick at least one option from this group.';
4369
+ case 'MIN_SELECTIONS_NOT_MET':
4370
+ case 'MAX_SELECTIONS_EXCEEDED':
4371
+ case 'SINGLE_GROUP_MULTIPLE_PICKS':
4372
+ return issue.message; // server's message already says "Pick at least N" / "Pick at most N"
4373
+ case 'UNKNOWN_MODIFIER':
4374
+ case 'UNKNOWN_GROUP':
4375
+ case 'MODIFIER_DISABLED_FOR_VARIANT':
4376
+ case 'MODIFIER_NOT_AVAILABLE':
4377
+ // The catalog moved under us \u2014 refetch the product and ask the customer
4378
+ // to re-pick. These codes mean the option simply isn't valid anymore.
4379
+ return 'This option is no longer available \u2014 please refresh.';
4380
+ case 'NESTED_DEPTH_EXCEEDED':
4381
+ case 'NESTED_REQUIRES_PRODUCT_REF':
4382
+ return issue.message; // bug in your renderer \u2014 never go past 3 levels
4383
+ case 'INVALID_PRICE_DELTA':
4384
+ return issue.message; // shouldn't happen for SDK callers \u2014 server-validated on save
4385
+ case 'MODIFIER_PRICE_FLOOR_VIOLATED':
4386
+ // The server reports a generic message \u2014 internals never leak. Show a
4387
+ // friendly error and let the customer remove a downsell.
4388
+ return 'Cannot apply more discounts on this item.';
4389
+ }
4390
+ }
4391
+
4392
+ // CLIENT-SIDE PRE-FLIGHT (UX):
4393
+ // You can mirror these checks before calling addToCart to give faster
4394
+ // feedback, but the SERVER is authoritative. Always treat the 400 envelope
4395
+ // as the source of truth.
4396
+ function preflightSelections(
4397
+ groups: { id: string; name: string; min: number; max?: number | null; required: boolean; selectionType: 'SINGLE' | 'MULTIPLE'; }[],
4398
+ selections: Record<string, string[]>
4399
+ ): string | null {
4400
+ for (const g of groups) {
4401
+ if (g.max === 0) continue; // disabled-for-variant
4402
+ const picks = selections[g.id] ?? [];
4403
+ if (g.required && picks.length === 0) return \`\${g.name} is required\`;
4404
+ if (picks.length < g.min) return \`Pick at least \${g.min} from \${g.name}\`;
4405
+ if (g.max != null && picks.length > g.max) return \`Pick at most \${g.max} from \${g.name}\`;
4406
+ if (g.selectionType === 'SINGLE' && picks.length > 1) return \`Only one option allowed in \${g.name}\`;
4407
+ }
4408
+ return null;
4409
+ }`,
4410
+ "checkout-price-drift": `// PRICE_DRIFT \u2014 a product's price changed between add-to-cart and checkout.
4411
+ // The server returns HTTP 400 with code "PRICE_DRIFT" when it detects that
4412
+ // the stored unitPrice no longer matches the live price.
4413
+ //
4414
+ // Strategy: catch the error \u2192 show "prices updated" dialog \u2192
4415
+ // call refreshCartSnapshots() to re-snapshot all live prices \u2192
4416
+ // retry createCheckout once.
4417
+
4418
+ import { client } from './brainerce-client';
4419
+
4420
+ interface CheckoutOptions {
4421
+ cartId: string;
4422
+ }
4423
+
4424
+ async function createCheckoutSafe(opts: CheckoutOptions) {
4425
+ try {
4426
+ // createCheckout only takes cartId (+ optional customerId, selectedItemIds).
4427
+ // Shipping address, shipping method, and payment are set via separate calls.
4428
+ return await client.createCheckout({ cartId: opts.cartId });
4429
+ } catch (err: unknown) {
4430
+ if (!isApiError(err, 'PRICE_DRIFT')) throw err;
4431
+
4432
+ // Prices changed \u2014 refresh snapshots then retry once.
4433
+ // refreshCartSnapshots updates every cart item's unitPrice to the current
4434
+ // live price (base price + any modifier deltas) so the next checkout call
4435
+ // will pass the drift guard.
4436
+ await client.refreshCartSnapshots(opts.cartId);
4437
+
4438
+ // After refreshing, re-fetch the cart so your UI shows the updated prices
4439
+ // before you retry, giving the customer a chance to review.
4440
+ const updatedCart = await client.getCart(opts.cartId);
4441
+
4442
+ // Signal to the UI layer that prices changed so it can show a dialog.
4443
+ throw Object.assign(new Error('PRICES_UPDATED'), {
4444
+ code: 'PRICES_UPDATED',
4445
+ updatedCart,
4446
+ });
4447
+ }
4448
+ }
4449
+
4450
+ function isApiError(err: unknown, code: string): boolean {
4451
+ return (
4452
+ typeof err === 'object' &&
4453
+ err !== null &&
4454
+ 'code' in err &&
4455
+ (err as { code: string }).code === code
4456
+ );
4457
+ }
4458
+
4459
+ // --- UI integration example (framework-neutral) ---
4460
+ //
4461
+ // async function handlePlaceOrder() {
4462
+ // try {
4463
+ // const checkout = await createCheckoutSafe({ cartId, ... });
4464
+ // router.push(\`/order-confirmation?checkoutId=\${checkout.id}\`);
4465
+ // } catch (err: unknown) {
4466
+ // if (isApiError(err, 'PRICES_UPDATED')) {
4467
+ // const { updatedCart } = err as { updatedCart: Cart };
4468
+ // showPricesUpdatedDialog(updatedCart); // show diff, let user confirm
4469
+ // return;
4470
+ // }
4471
+ // showGenericError(err);
4472
+ // }
4473
+ // }
4474
+ //
4475
+ // The dialog should:
4476
+ // 1. Show which items changed price (compare old vs new unitPrice)
4477
+ // 2. Offer "Continue with new prices" (calls createCheckoutSafe again)
4478
+ // and "Go back to cart" (returns to cart page)
4479
+ // 3. NOT silently retry \u2014 the customer must acknowledge the price change.`,
4480
+ "cart-item-modifier-display": `// Rendering cart items that have modifier selections.
4481
+ // Each cart item has a \`modifiers\` array \u2014 each entry records the modifier
4482
+ // name, the price delta at time of add-to-cart, and whether it was free
4483
+ // (inside the group's freeQuantity allowance).
4484
+ //
4485
+ // Typical display:
4486
+ // Margherita \u20AA45.00
4487
+ // Extra cheese +\u20AA5.00
4488
+ // Mushrooms free
4489
+ // No onions \u2014
4490
+ // \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
4491
+ // Base \u20AA45.00 + Modifiers \u20AA5.00 = \u20AA50.00
4492
+
4493
+ import { formatPrice } from 'brainerce';
4494
+
4495
+ interface CartItemModifier {
4496
+ modifierId: string;
4497
+ name: string;
4498
+ priceDeltaAtTime: number; // in store currency units, e.g. 5.00
4499
+ freeApplied: boolean; // true when inside the group's freeQuantity
4500
+ }
4501
+
4502
+ interface CartItem {
4503
+ id: string;
4504
+ name: string;
4505
+ unitPrice: number; // base + all chargeable modifier deltas, as stored
4506
+ quantity: number;
4507
+ modifiers?: CartItemModifier[];
4508
+ currency: string; // e.g. 'ILS', 'USD'
4509
+ }
4510
+
4511
+ function renderCartItem(item: CartItem): string {
4512
+ const currency = item.currency;
4513
+ const lines: string[] = [];
4514
+
4515
+ lines.push(\`\${item.name} \${formatPrice(item.unitPrice, { currency })}\`);
4516
+
4517
+ const chargeableModifiers = (item.modifiers ?? []).filter(
4518
+ (m) => !m.freeApplied && m.priceDeltaAtTime !== 0
4519
+ );
4520
+ const freeModifiers = (item.modifiers ?? []).filter((m) => m.freeApplied);
4521
+ const zeroModifiers = (item.modifiers ?? []).filter(
4522
+ (m) => !m.freeApplied && m.priceDeltaAtTime === 0
4523
+ );
4524
+
4525
+ for (const m of chargeableModifiers) {
4526
+ const sign = m.priceDeltaAtTime > 0 ? '+' : '';
4527
+ lines.push(\` \${m.name} \${sign}\${formatPrice(m.priceDeltaAtTime, { currency })}\`);
4528
+ }
4529
+ for (const m of freeModifiers) {
4530
+ lines.push(\` \${m.name} free\`);
4531
+ }
4532
+ for (const m of zeroModifiers) {
4533
+ lines.push(\` \${m.name} \u2014\`);
4534
+ }
4535
+
4536
+ // Price breakdown: only show when there are chargeable modifiers
4537
+ const modifiersTotal = chargeableModifiers.reduce(
4538
+ (sum, m) => sum + m.priceDeltaAtTime,
4539
+ 0
4540
+ );
4541
+ if (modifiersTotal !== 0) {
4542
+ // unitPrice already includes modifiers \u2014 derive base for display only
4543
+ const basePrice = item.unitPrice - modifiersTotal;
4544
+ const base = formatPrice(basePrice, { currency }) as string;
4545
+ const mods = formatPrice(modifiersTotal, { currency }) as string;
4546
+ const total = formatPrice(item.unitPrice, { currency }) as string;
4547
+ lines.push(\`Base \${base} + Modifiers \${mods} = \${total}\`);
4548
+ }
4549
+
4550
+ return lines.join('\\n');
4551
+ }
4552
+
4553
+ // IMPORTANT: unitPrice already includes all chargeable modifier deltas.
4554
+ // Do NOT add modifier prices on top of unitPrice when computing line totals \u2014
4555
+ // multiply unitPrice \xD7 quantity directly.
4556
+ function lineTotal(item: CartItem): number {
4557
+ return item.unitPrice * item.quantity;
4558
+ }`
3559
4559
  };
3560
4560
  async function handleGetCodeExample(args) {
3561
4561
  const snippet = SNIPPETS[args.operation];
@@ -3671,13 +4671,27 @@ function getCandidateApiUrls() {
3671
4671
 
3672
4672
  // src/tools/get-store-info.ts
3673
4673
  var GET_STORE_INFO_NAME = "get-store-info";
3674
- 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.";
4674
+ 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.";
3675
4675
  var GET_STORE_INFO_SCHEMA = {
3676
- connectionId: import_zod4.z.string().describe("Vibe-coded connection ID (starts with vc_)")
4676
+ salesChannelId: import_zod4.z.string().optional().describe("Sales channel ID (starts with vc_)"),
4677
+ /** @deprecated alias of salesChannelId */
4678
+ connectionId: import_zod4.z.string().optional().describe("Deprecated alias of salesChannelId \u2014 kept for backwards compat")
3677
4679
  };
3678
4680
  async function handleGetStoreInfo(args) {
4681
+ const id = args.salesChannelId ?? args.connectionId;
4682
+ if (!id) {
4683
+ return {
4684
+ content: [{ type: "text", text: "Error: salesChannelId is required" }],
4685
+ isError: true
4686
+ };
4687
+ }
4688
+ if (!args.salesChannelId && args.connectionId) {
4689
+ console.warn(
4690
+ "get-store-info: `connectionId` is deprecated \u2014 use `salesChannelId` instead"
4691
+ );
4692
+ }
3679
4693
  try {
3680
- const resolved = await resolveStoreInfo(args.connectionId, getCandidateApiUrls());
4694
+ const resolved = await resolveStoreInfo(id, getCandidateApiUrls());
3681
4695
  return {
3682
4696
  content: [
3683
4697
  {
@@ -3690,7 +4704,8 @@ async function handleGetStoreInfo(args) {
3690
4704
  storeName: resolved.info.storeName,
3691
4705
  currency: resolved.info.currency,
3692
4706
  language: resolved.info.language,
3693
- connectionId: args.connectionId,
4707
+ salesChannelId: id,
4708
+ connectionId: id,
3694
4709
  apiBaseUrl: resolved.apiBaseUrl
3695
4710
  },
3696
4711
  null,
@@ -3775,9 +4790,11 @@ async function resolveStoreCapabilities(connectionId, candidateUrls) {
3775
4790
 
3776
4791
  // src/tools/get-store-capabilities.ts
3777
4792
  var GET_STORE_CAPABILITIES_NAME = "get-store-capabilities";
3778
- 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.";
4793
+ 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.";
3779
4794
  var GET_STORE_CAPABILITIES_SCHEMA = {
3780
- connectionId: import_zod5.z.string().describe("Vibe-coded connection ID (starts with vc_)")
4795
+ salesChannelId: import_zod5.z.string().optional().describe("Sales channel ID (starts with vc_)"),
4796
+ /** @deprecated alias of salesChannelId */
4797
+ connectionId: import_zod5.z.string().optional().describe("Deprecated alias of salesChannelId \u2014 kept for backwards compat")
3781
4798
  };
3782
4799
  function formatCapabilities(caps) {
3783
4800
  const lines = [];
@@ -3890,15 +4907,27 @@ function formatCapabilities(caps) {
3890
4907
  }
3891
4908
  if (suggestions.length === 0) {
3892
4909
  suggestions.push(
3893
- "Start by building the required features. Use get-required-features with this connectionId for the checklist."
4910
+ "Start by building the required features. Use get-required-features with this salesChannelId for the checklist."
3894
4911
  );
3895
4912
  }
3896
4913
  suggestions.forEach((s) => lines.push(`- ${s}`));
3897
4914
  return lines.join("\n");
3898
4915
  }
3899
4916
  async function handleGetStoreCapabilities(args) {
4917
+ const id = args.salesChannelId ?? args.connectionId;
4918
+ if (!id) {
4919
+ return {
4920
+ content: [{ type: "text", text: "Error: salesChannelId is required" }],
4921
+ isError: true
4922
+ };
4923
+ }
4924
+ if (!args.salesChannelId && args.connectionId) {
4925
+ console.warn(
4926
+ "get-store-capabilities: `connectionId` is deprecated \u2014 use `salesChannelId` instead"
4927
+ );
4928
+ }
3900
4929
  try {
3901
- const resolved = await resolveStoreCapabilities(args.connectionId, getCandidateApiUrls());
4930
+ const resolved = await resolveStoreCapabilities(id, getCandidateApiUrls());
3902
4931
  return {
3903
4932
  content: [{ type: "text", text: formatCapabilities(resolved.capabilities) }]
3904
4933
  };
@@ -4003,7 +5032,7 @@ var RULES = {
4003
5032
  tokens: {
4004
5033
  title: "Auth tokens & BFF pattern",
4005
5034
  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\`.
4006
- - NEVER put the admin API key (\`brainerce_*\`) in client code. It is a server-only secret. Client code uses \`connectionId\` or storefront endpoints.
5035
+ - 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.
4007
5036
  - 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.
4008
5037
  - 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.`
4009
5038
  },
@@ -4071,21 +5100,21 @@ var FLOWS = {
4071
5100
  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\`.
4072
5101
  2. **Submit the address to get shipping rates.**
4073
5102
  \`\`\`ts
4074
- const { shippingRates } = await client.setShippingAddress(checkoutId, {
5103
+ const { checkout, rates } = await client.setShippingAddress(checkoutId, {
4075
5104
  email,
4076
5105
  firstName,
4077
5106
  lastName,
4078
5107
  line1, line2, city, region, postalCode, country,
4079
5108
  });
4080
5109
  \`\`\`
4081
- The response includes the available shipping rates for that address + the store's zones.
5110
+ \`rates\` = available shipping rates for that address + the store's zones. \`checkout\` = updated checkout object.
4082
5111
  3. **Let the customer pick a rate**, then persist the selection:
4083
5112
  \`\`\`ts
4084
- await client.setShippingMethod(checkoutId, rateId);
5113
+ await client.selectShippingMethod(checkoutId, rateId);
4085
5114
  \`\`\`
4086
- 4. **Fetch payment providers for this checkout:**
5115
+ 4. **Fetch payment providers:**
4087
5116
  \`\`\`ts
4088
- const providers = await client.getPaymentProviders(checkoutId);
5117
+ const providers = await client.getPaymentProviders();
4089
5118
  \`\`\`
4090
5119
  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.
4091
5120
  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.
@@ -4101,28 +5130,28 @@ Never bypass these steps. Never call \`submitGuestOrder\` / \`createOrder\` \u20
4101
5130
  "auth-register": {
4102
5131
  title: "Registration",
4103
5132
  body: `1. **Collect** email, password, first name, last name. Enforce strong passwords client-side: 8+ chars, upper, lower, number, special.
4104
- 2. **Call register:**
5133
+ 2. **Call registerCustomer:**
4105
5134
  \`\`\`ts
4106
- const result = await client.register({ email, password, firstName, lastName });
5135
+ const result = await client.registerCustomer({ email, password, firstName, lastName });
4107
5136
  \`\`\`
4108
5137
  3. **Branch on \`result.requiresVerification\`:**
4109
- - If \`true\`: route the user to your verify-email UI. Do NOT treat them as logged in yet.
4110
- - If \`false\`: the user is logged in. Store the token via your BFF session and route to the account area.
5138
+ - 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.
5139
+ - If \`false\`: call \`client.setCustomerToken(result.token)\` and route to the account area.
4111
5140
  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()\`.
4112
- 5. **After verifyEmail resolves:** the user is now logged in. Persist the session via your BFF and route to the account area.
5141
+ 5. **After verifyEmail resolves:** call \`client.setCustomerToken(result.token)\` \u2014 the user is now logged in \u2014 then route to the account area.
4113
5142
 
4114
5143
  Build the verify-email step EVEN IF the store currently has verification disabled. It auto-hides; store owners enable it later.`
4115
5144
  },
4116
5145
  "auth-login": {
4117
5146
  title: "Login",
4118
5147
  body: `1. **Collect** email + password.
4119
- 2. **Call login:**
5148
+ 2. **Call loginCustomer:**
4120
5149
  \`\`\`ts
4121
- const result = await client.login({ email, password });
5150
+ const result = await client.loginCustomer(email, password);
4122
5151
  \`\`\`
4123
5152
  3. **Branch on \`result.requiresVerification\`:**
4124
5153
  - If \`true\`: route to verify-email. The user must complete verification before accessing account features.
4125
- - If \`false\`: persist the session via your BFF and route to the previous page (or account area).
5154
+ - If \`false\`: call \`client.setCustomerToken(result.token)\` and route to the previous page (or account area).
4126
5155
  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.
4127
5156
  5. **On error**, render the specific message (invalid credentials, rate limited, account disabled) \u2014 never swallow.`
4128
5157
  },
@@ -4147,15 +5176,24 @@ Build both steps EVEN IF the store has no email provider configured today \u2014
4147
5176
  },
4148
5177
  oauth: {
4149
5178
  title: "OAuth sign-in",
4150
- body: `1. **Render available providers:**
5179
+ body: `1. **Get available provider names:**
4151
5180
  \`\`\`ts
4152
- const providers = await client.getAvailableOAuthProviders();
5181
+ const { providers } = await client.getAvailableOAuthProviders();
5182
+ // providers = ['GOOGLE', 'FACEBOOK', 'GITHUB'] (strings, not objects with authorizationUrl)
4153
5183
  \`\`\`
4154
- Each provider has \`{ provider: 'GOOGLE' | 'FACEBOOK' | 'GITHUB', authorizationUrl }\`.
4155
- 2. **Build a button for each** that redirects the browser to \`authorizationUrl\`. Do NOT open a popup; the full-page redirect is required.
4156
- 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.
4157
- 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.
4158
- 5. **On \`oauth_error\`:** redirect to login with an error message.
5184
+ 2. **For each provider, fetch the authorization URL:**
5185
+ \`\`\`ts
5186
+ const { authorizationUrl } = await client.getOAuthAuthorizeUrl(provider, {
5187
+ redirectUrl: \`\${window.location.origin}/auth/callback\`,
5188
+ });
5189
+ window.location.href = authorizationUrl; // full-page redirect, NOT a popup
5190
+ \`\`\`
5191
+ 3. **On the callback page** the URL contains \`token\` + \`oauth_success\` (or \`oauth_error\`) query params. Extract and apply the token:
5192
+ \`\`\`ts
5193
+ const token = new URLSearchParams(location.search).get('token');
5194
+ if (token) client.setCustomerToken(token); // then redirect to account
5195
+ \`\`\`
5196
+ 4. **On \`oauth_error\`:** redirect to login with an error message.
4159
5197
 
4160
5198
  Build the OAuth button region AND the callback handler even when no providers are configured. They auto-hide.`
4161
5199
  },
@@ -4177,7 +5215,7 @@ Build the OAuth button region AND the callback handler even when no providers ar
4177
5215
 
4178
5216
  - **Cart ID persistence:** the SDK stores the cart ID across reloads. You do not need to write cart-to-localStorage code yourself.
4179
5217
  - **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).
4180
- - **Writes:** use \`client.addToCart\`, \`client.updateCartItem\`, \`client.removeCartItem\`, \`client.applyCoupon\`, \`client.removeCoupon\`. After each mutation the SDK returns the updated cart.
5218
+ - **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.
4181
5219
  - **Totals:** call \`getCartTotals(cart)\` \u2014 do NOT read \`cart.total\`. The helper understands taxes, shipping, and discounts.
4182
5220
  - **\`smartGetCart()\`** returns \`CartWithIncludes\` (extends \`Cart\`). All carts are server-side. Pass \`{ include: ['recommendations', 'upgrades', 'bundles'] }\` to fetch extras in one request.
4183
5221
  - **NEVER mutate cart state outside SDK helpers.** Any hand-rolled cart update risks desync with the reservation timer and the checkout flow.`
@@ -4296,18 +5334,20 @@ ${flow.body}`
4296
5334
  // src/tools/get-required-features.ts
4297
5335
  var import_zod9 = require("zod");
4298
5336
  var GET_REQUIRED_FEATURES_NAME = "get-required-features";
4299
- 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.";
5337
+ 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.";
4300
5338
  var GET_REQUIRED_FEATURES_SCHEMA = {
4301
- connectionId: import_zod9.z.string().optional().describe(
4302
- "Vibe-coded connection ID (starts with vc_). Optional \u2014 without it, returns the generic checklist."
4303
- )
5339
+ salesChannelId: import_zod9.z.string().optional().describe(
5340
+ "Sales channel ID (starts with vc_). Optional \u2014 without it, returns the generic checklist."
5341
+ ),
5342
+ /** @deprecated alias of salesChannelId */
5343
+ connectionId: import_zod9.z.string().optional().describe("Deprecated alias of salesChannelId \u2014 kept for backwards compat")
4304
5344
  };
4305
5345
  var FEATURES = [
4306
5346
  {
4307
5347
  id: "browse-products",
4308
5348
  title: "Browse, filter, and search products",
4309
- 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.",
4310
- sdk: "client.getProducts({ page, limit, filters, sort }), client.searchProducts(query)",
5349
+ 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.",
5350
+ sdk: "client.getProducts({ page, limit, filters, sort, metafields }), client.getPublicMetafieldDefinitions(), client.getSearchSuggestions(query)",
4311
5351
  mandatory: "mandatory"
4312
5352
  },
4313
5353
  {
@@ -4337,7 +5377,7 @@ var FEATURES = [
4337
5377
  id: "cart-coupons",
4338
5378
  title: "Apply and remove coupon codes",
4339
5379
  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.",
4340
- sdk: "client.applyCoupon(code), client.removeCoupon()",
5380
+ 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)",
4341
5381
  mandatory: "mandatory",
4342
5382
  capabilityFlag: "hasCoupons",
4343
5383
  whenDisabledNote: "Store has no coupons configured today. Build the UI anyway \u2014 it auto-hides until the store owner creates a coupon."
@@ -4354,7 +5394,7 @@ var FEATURES = [
4354
5394
  id: "checkout",
4355
5395
  title: "Complete a full checkout end-to-end",
4356
5396
  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.",
4357
- sdk: "client.setShippingAddress, client.setShippingMethod, client.getPaymentProviders, provider-specific confirm, client.handlePaymentSuccess, client.waitForOrder",
5397
+ sdk: "client.setShippingAddress (returns { checkout, rates }), client.selectShippingMethod, client.getPaymentProviders(), provider-specific confirm, client.handlePaymentSuccess, client.waitForOrder",
4358
5398
  flowRef: "checkout",
4359
5399
  mandatory: "mandatory"
4360
5400
  },
@@ -4370,7 +5410,7 @@ var FEATURES = [
4370
5410
  id: "register",
4371
5411
  title: "Register a new account with email verification",
4372
5412
  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.',
4373
- sdk: "client.register({email, password, firstName, lastName}), client.verifyEmail(code), client.resendVerificationEmail()",
5413
+ sdk: "client.registerCustomer({email, password, firstName, lastName}), client.verifyEmail(code), client.resendVerificationEmail()",
4374
5414
  flowRef: "auth-register",
4375
5415
  mandatory: "mandatory",
4376
5416
  capabilityFlag: "oauth",
@@ -4380,7 +5420,7 @@ var FEATURES = [
4380
5420
  id: "login",
4381
5421
  title: "Log in with email / password and handle verification branch",
4382
5422
  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).",
4383
- sdk: "client.login({email, password})",
5423
+ sdk: "client.loginCustomer(email, password)",
4384
5424
  flowRef: "auth-login",
4385
5425
  mandatory: "mandatory"
4386
5426
  },
@@ -4413,14 +5453,14 @@ var FEATURES = [
4413
5453
  id: "header",
4414
5454
  title: "Global header with cart count and search",
4415
5455
  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.",
4416
- sdk: "client.getCart() for the count, client.searchProducts(query) for autocomplete",
5456
+ sdk: "client.getCart() for the count, client.getSearchSuggestions(query) for autocomplete",
4417
5457
  mandatory: "mandatory"
4418
5458
  },
4419
5459
  {
4420
5460
  id: "discount-banners",
4421
5461
  title: "Show active discount banners and badges",
4422
5462
  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.",
4423
- sdk: "client.getDiscountBanners(), getProductDiscountBadge(product)",
5463
+ sdk: "client.getDiscountBanners(), client.getProductDiscountBadge(productId)",
4424
5464
  mandatory: "mandatory",
4425
5465
  capabilityFlag: "hasDiscountRules",
4426
5466
  whenDisabledNote: "Store has no discount rules active. Build the banner and badge components anyway \u2014 they auto-hide."
@@ -4522,10 +5562,16 @@ function renderCapabilitiesSummary(caps) {
4522
5562
  return lines.join("\n");
4523
5563
  }
4524
5564
  async function handleGetRequiredFeatures(args) {
5565
+ const id = args.salesChannelId ?? args.connectionId;
5566
+ if (!args.salesChannelId && args.connectionId) {
5567
+ console.warn(
5568
+ "get-required-features: `connectionId` is deprecated \u2014 use `salesChannelId` instead"
5569
+ );
5570
+ }
4525
5571
  let caps = null;
4526
- if (args.connectionId) {
5572
+ if (id) {
4527
5573
  try {
4528
- const resolved = await resolveStoreCapabilities(args.connectionId, getCandidateApiUrls());
5574
+ const resolved = await resolveStoreCapabilities(id, getCandidateApiUrls());
4529
5575
  caps = resolved.capabilities;
4530
5576
  } catch {
4531
5577
  caps = null;
@@ -4537,11 +5583,11 @@ async function handleGetRequiredFeatures(args) {
4537
5583
  );
4538
5584
  if (caps) {
4539
5585
  sections.push(renderCapabilitiesSummary(caps));
4540
- } else if (args.connectionId) {
5586
+ } else if (id) {
4541
5587
  sections.push(
4542
5588
  `## Live store capabilities
4543
5589
 
4544
- Could not fetch live capabilities for \`${args.connectionId}\`. Proceeding with the generic checklist. Call \`get-store-capabilities\` separately if you want a targeted checklist.`
5590
+ Could not fetch live capabilities for \`${id}\`. Proceeding with the generic checklist. Call \`get-store-capabilities\` separately if you want a targeted checklist.`
4545
5591
  );
4546
5592
  }
4547
5593
  sections.push("## Features");