@brainerce/mcp-server 3.2.0 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -291,9 +291,14 @@ 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
304
  setPaymentData({ clientSecret: paymentIntent.clientSecret, provider: paymentIntent.provider, checkoutId });
@@ -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
@@ -1140,22 +1228,24 @@ const { upgrades } = await client.getCartUpgrades(cartId);
1140
1228
  // Show inline banner per cart item if upgrade exists
1141
1229
  \`\`\`
1142
1230
 
1143
- **Bundle offers** (discounted complementary products configured by the store owner):
1231
+ **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
1232
  \`\`\`typescript
1145
1233
  import type { CartBundlesResponse } from 'brainerce';
1146
1234
  const { bundles } = await client.getCartBundles(cartId);
1147
- // bundles[].bundleProduct \u2014 the product to offer
1148
- // bundles[].originalPrice / bundles[].discountedPrice \u2014 prices
1235
+ // bundles[].triggerProductId \u2014 already in cart, activates the offer
1236
+ // bundles[].productIds \u2014 full bundle composition (length >= 2)
1237
+ // bundles[].offeredProducts[] \u2014 products customer hasn't added yet
1238
+ // each with originalPrice + discountedPrice
1239
+ // bundles[].totalOriginalPrice / totalDiscountedPrice \u2014 sums across offeredProducts
1149
1240
  // 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
1241
 
1154
- // Add a bundle to cart (applies the discount automatically):
1242
+ // Accept a bundle (adds every offered product not yet in cart at the discount):
1155
1243
  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:
1244
+ // If some offered products have variants, pass per-product variant selections:
1245
+ await client.addBundleToCart(cartId, bundleOfferId, {
1246
+ [variantProductId]: selectedVariantId,
1247
+ });
1248
+ // Remove an accepted bundle (removes every cart item linked to that bundle):
1159
1249
  await client.removeBundleFromCart(cartId, bundleOfferId);
1160
1250
  // Detect already-added bundles: check cart.items for metadata?.isBundleItem === true
1161
1251
  \`\`\`
@@ -1211,11 +1301,12 @@ Always check the flag before rendering: \`if (storeInfo?.upsell?.featureName !==
1211
1301
  | CartRecommendationSection | products/ | Cross-sell grid at bottom of cart page |
1212
1302
  | FreeShippingBar | cart/ | Progress bar toward free shipping threshold |
1213
1303
  | 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) |
1304
+ | CartBundleOfferCard | cart/ | N-product bundle offer card in cart \u2014 lists every offered product with its discounted price and an "Add bundle" button |
1215
1305
  | OrderBumpCard | checkout/ | Checkbox add-on card in checkout sidebar (with inline variant selector for variable products) |
1216
1306
 
1217
1307
  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 }\`).`;
1308
+ OrderBump: includes \`requiresVariantSelection\` (boolean) and \`lockedVariant?\` (\`{ id, name, attributes }\`).
1309
+ 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
1310
  }
1220
1311
  function getProductCustomizationFieldsSection() {
1221
1312
  return `## Product Customization Fields (buyer input on product page)
@@ -1321,6 +1412,157 @@ When the order is created, each line's customization values are snapshotted onto
1321
1412
  - Using a raw external URL (not from \`/customization-upload\`) for \`IMAGE\` / \`GALLERY\` \u2192 HTTP 400
1322
1413
  - Assuming \`enumValues\` is present for all types \u2014 only \`SELECT\` / \`MULTI_SELECT\` require it`;
1323
1414
  }
1415
+ function getModifierGroupsSection() {
1416
+ return `## Modifier Groups (toppings, sauce, build-your-own)
1417
+
1418
+ 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.
1419
+
1420
+ If \`product.modifierGroups\` is empty or missing, render the product page normally and skip everything below.
1421
+
1422
+ ### Wire shape on \`GET /products/:id\`
1423
+
1424
+ \`\`\`typescript
1425
+ import type { Product, ModifierGroup, Modifier, ModifierSelection } from 'brainerce';
1426
+
1427
+ const product = await client.getProductBySlug(slug);
1428
+ const groups: ModifierGroup[] = product.modifierGroups ?? [];
1429
+
1430
+ // Each group looks like:
1431
+ // {
1432
+ // id: 'mg_toppings',
1433
+ // attachmentId: 'pmg_01', // ProductModifierGroup row \u2014 used for attach updates
1434
+ // name: 'Toppings', // customer-facing
1435
+ // internalName?: string, // ADMIN ONLY \u2014 never present in storefront responses
1436
+ // selectionType: 'SINGLE' | 'MULTIPLE',
1437
+ // min: number, // effective (overrides already applied)
1438
+ // max: number | null, // null = unlimited; **0 = group hidden for this variant**
1439
+ // freeQuantity: number, // first N picks at no extra cost
1440
+ // required: boolean,
1441
+ // freeAllocationPolicy: 'EXPENSIVE_FREE' | 'CHEAPEST_FREE' | 'SELECTION_ORDER',
1442
+ // defaultModifierIds: string[], // pre-checked on first render
1443
+ // modifiers: Array<{
1444
+ // id: 'm_olive',
1445
+ // name: 'Olives',
1446
+ // priceDelta: '5.00', // DECIMAL STRING \u2014 never JSON Number
1447
+ // position: 0,
1448
+ // isDefault: false, // pre-check this option even if not in defaultModifierIds
1449
+ // available: true, // false \u2192 "sold out", disabled in UI
1450
+ // referencedProductId?: string, // nested-combo target (depth \u2264 3)
1451
+ // }>,
1452
+ // }
1453
+ \`\`\`
1454
+
1455
+ **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.
1456
+
1457
+ ### Render: SINGLE \u2192 radio, MULTIPLE \u2192 checkbox
1458
+
1459
+ Walk \`groups\` and pick the input type by \`selectionType\`. Sort modifiers by \`position\`.
1460
+
1461
+ \`\`\`typescript
1462
+ for (const group of groups) {
1463
+ if (group.max === 0) continue; // disabled-for-variant convention \u2014 skip entirely
1464
+ const inputType = group.selectionType === 'SINGLE' ? 'radio' : 'checkbox';
1465
+ const sorted = [...group.modifiers].sort((a, b) => a.position - b.position);
1466
+ // Render fieldset \u2192 legend with name + " *" if required \u2192 list of sorted options.
1467
+ // Each option: input(type=inputType, name=group.id, value=modifier.id),
1468
+ // disabled when modifier.available === false, with a "Sold out" badge.
1469
+ }
1470
+ \`\`\`
1471
+
1472
+ When \`group.freeQuantity > 0\`, show a running counter so the customer understands the rule:
1473
+ \`\`\`
1474
+ {Math.min(picks.length, group.freeQuantity)} of {group.freeQuantity} free
1475
+ \`\`\`
1476
+
1477
+ 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.
1478
+
1479
+ ### Initial state
1480
+
1481
+ 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.
1482
+
1483
+ ### Pass selections on add-to-cart
1484
+
1485
+ \`\`\`typescript
1486
+ const selections: ModifierSelection[] = [
1487
+ { modifierGroupId: 'mg_bread', modifierIds: ['m_thick'] },
1488
+ { modifierGroupId: 'mg_toppings', modifierIds: ['m_olive', 'm_mushroom', 'm_bacon', 'm_egg'] },
1489
+ ];
1490
+
1491
+ await client.smartAddToCart({
1492
+ productId: product.id,
1493
+ variantId: selectedVariant?.id,
1494
+ quantity: 1,
1495
+ selections,
1496
+ });
1497
+ \`\`\`
1498
+
1499
+ \`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:
1500
+
1501
+ \`\`\`typescript
1502
+ // cart.items[i].modifiers \u2014 same shape as ModifierSelection but with snapshot data
1503
+ [
1504
+ { modifierId: 'm_olive', name: 'Olives', priceDelta: '5.00', freeApplied: true },
1505
+ { modifierId: 'm_mushroom', name: 'Mushrooms', priceDelta: '5.00', freeApplied: true },
1506
+ { modifierId: 'm_bacon', name: 'Bacon', priceDelta: '7.00', freeApplied: true },
1507
+ { modifierId: 'm_egg', name: 'Egg', priceDelta: '6.00', freeApplied: false },
1508
+ ]
1509
+ // + cart.items[i].modifiersTotal \u2014 sum of paid (non-free) deltas as a decimal string
1510
+ \`\`\`
1511
+
1512
+ \`freeApplied: true\` means the modifier consumed a free slot \u2014 render with a "free" badge in the cart UI.
1513
+
1514
+ ### Editing selections after add-to-cart (idempotent)
1515
+
1516
+ \`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).
1517
+
1518
+ \`\`\`typescript
1519
+ await client.updateCartItem(cart.id, itemId, {
1520
+ quantity: 1,
1521
+ selections: [{ modifierGroupId: 'mg_toppings', modifierIds: ['m_olive', 'm_mushroom'] }],
1522
+ });
1523
+ \`\`\`
1524
+
1525
+ ### Validation envelope
1526
+
1527
+ When the payload is invalid the server returns HTTP 400 with a structured envelope. The SDK exposes it on \`BrainerceError.details\`:
1528
+
1529
+ \`\`\`typescript
1530
+ try {
1531
+ await client.smartAddToCart({ productId, quantity: 1, selections });
1532
+ } catch (err) {
1533
+ const e = err as { statusCode?: number; details?: { code?: string; errors?: Array<{ code: string; message: string; modifierGroupId?: string; modifierId?: string }> } };
1534
+ if (e.statusCode === 400 && e.details?.code === 'MODIFIER_VALIDATION_FAILED') {
1535
+ for (const issue of e.details.errors ?? []) {
1536
+ // issue.code is one of: REQUIRED_GROUP_MISSING | MIN_SELECTIONS_NOT_MET |
1537
+ // MAX_SELECTIONS_EXCEEDED | SINGLE_GROUP_MULTIPLE_PICKS | UNKNOWN_MODIFIER |
1538
+ // UNKNOWN_GROUP | MODIFIER_DISABLED_FOR_VARIANT | MODIFIER_NOT_AVAILABLE |
1539
+ // NESTED_DEPTH_EXCEEDED | NESTED_REQUIRES_PRODUCT_REF | INVALID_PRICE_DELTA |
1540
+ // MODIFIER_PRICE_FLOOR_VIOLATED
1541
+ console.error(issue.message);
1542
+ }
1543
+ }
1544
+ }
1545
+ \`\`\`
1546
+
1547
+ 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.
1548
+
1549
+ ### Disable-for-variant convention (PRD \xA77.2.2)
1550
+
1551
+ 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.
1552
+
1553
+ ### Common mistakes
1554
+
1555
+ - Treating \`priceDelta\` as a number \u2192 arithmetic precision bugs. Always strings; use \`parseFloat\` only at display time.
1556
+ - Computing the line total client-side \u2192 diverges from the server's free-allocation. Render the server's \`unitPrice\` / \`modifiers[]\` / \`modifiersTotal\` instead.
1557
+ - Rendering a group with \`max: 0\` \u2192 it's the variant-disable signal; treat as absent.
1558
+ - 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.
1559
+ - Building \`selections\` keyed by \`modifier.name\` \u2192 must be \`modifierGroupId\` + \`modifierIds[]\` (the IDs, not names).
1560
+ - Sending \`modifiers\` instead of \`selections\` on add-to-cart \u2192 \`modifiers\` is the response field; the request key is \`selections\`.
1561
+
1562
+ ### Restaurant features (advanced)
1563
+
1564
+ 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.`;
1565
+ }
1324
1566
  function getInventorySection() {
1325
1567
  return `## Inventory, Stock Display & Reservation Countdown
1326
1568
 
@@ -1886,7 +2128,358 @@ const admin = new BrainerceClient({ apiKey: 'brainerce_...' });
1886
2128
  // Email: getEmailTemplates(), createEmailTemplate()
1887
2129
  // Conflicts: getSyncConflicts(), resolveSyncConflict()
1888
2130
  // OAuth: getOAuthProviders(), configureOAuthProvider()
1889
- \`\`\``;
2131
+ \`\`\`
2132
+
2133
+ ### Per-Channel Publishing
2134
+
2135
+ Categories, tags, brands, and metafield definitions are gated to specific
2136
+ vibe-coded sites \u2014 same control already exposed for products and coupons.
2137
+ **Explicit opt-in:** an entity is visible to a vibe-coded site only if it
2138
+ has been explicitly published to that connection. Entities with no publish
2139
+ rows are invisible to every site (the merchant must publish them via the
2140
+ dashboard or the admin SDK).
2141
+
2142
+ \`\`\`typescript
2143
+ // Publish to a specific vibe-coded site (admin mode):
2144
+ await admin.publishCategoryToVibeCodedSite('cat_id', 'conn_id');
2145
+ await admin.publishTagToVibeCodedSite('tag_id', 'conn_id');
2146
+ await admin.publishBrandToVibeCodedSite('brand_id', 'conn_id');
2147
+ await admin.publishMetafieldDefinitionToVibeCodedSite('def_id', 'conn_id');
2148
+
2149
+ // Unpublish (entity stays visible to other sites unless they also have publishes):
2150
+ await admin.unpublishCategoryFromVibeCodedSite('cat_id', 'conn_id');
2151
+ // ...same for tag/brand/metafield definition.
2152
+
2153
+ // Read which sites an entity is published to via list/get responses:
2154
+ const cat = await admin.getCategory('cat_id');
2155
+ cat.vibeCodedPublishes; // [{ connection: { id, name, connectionId } }, ...]
2156
+ \`\`\`
2157
+
2158
+ Cross-account isolation is enforced server-side: a publish call only
2159
+ succeeds when entity and connection belong to the same account. Cross-account
2160
+ calls fail with \`404 Not Found\`.
2161
+
2162
+ The vibe-coded read endpoints (used by storefront SDK calls in \`connectionId\`
2163
+ mode) automatically filter by these junction tables, so storefronts never see
2164
+ entities that weren't published to their site.`;
2165
+ }
2166
+ function getContactInquiriesSection() {
2167
+ return `## Contact Inquiries & Forms (optional)
2168
+
2169
+ Accept messages from one or more contact forms on your storefront. The merchant
2170
+ configures everything in the Brainerce dashboard under **Customers \u2192 Contact
2171
+ Forms**; submissions land in **Customers \u2192 Inquiries** and both sides receive
2172
+ email notifications.
2173
+
2174
+ **IMPORTANT for AI code generators:** the merchant OWNS the form shape. Do not
2175
+ hardcode labels, placeholders, help text, the submit button, or the success
2176
+ message. Render from the schema returned by \`contactForms.get()\` so the
2177
+ storefront reflects whatever the merchant configures \u2014 including new fields
2178
+ they add later, translations they add later, and the success message they
2179
+ write. This is the difference between "a contact form" and "THE merchant's
2180
+ contact form."
2181
+
2182
+ ### Two integration paths
2183
+
2184
+ 1. **Simple (legacy)** \u2014 pass \`{ name, email, subject, message }\` and submit.
2185
+ Works out of the box, no dashboard setup needed. Backed by a lazily-seeded
2186
+ default form keyed \`"main"\` with five built-in fields (\`name\`, \`email\`,
2187
+ \`phone\`, \`subject\`, \`message\`). Choose this only for throwaway demos.
2188
+ 2. **Flexible (recommended, default in \`npx create-brainerce-store\`)** \u2014
2189
+ merchants can configure multiple forms per store (e.g. \`main\`,
2190
+ \`newsletter\`, \`whatsapp_prechat\`), add custom fields (TEXT, TEXTAREA,
2191
+ EMAIL, PHONE, NUMBER, SELECT, MULTI_SELECT, CHECKBOX, URL, DATE), translate
2192
+ everything per locale, hide any non-essential built-in. Fetch the schema
2193
+ with \`contactForms.get(formKey, locale)\` and render dynamically.
2194
+
2195
+ ### Simple path (legacy shape)
2196
+
2197
+ \`\`\`typescript
2198
+ import type { CreateInquiryInput, CreateInquiryResponse } from 'brainerce';
2199
+
2200
+ const result = await brainerce.createInquiry({
2201
+ name: 'Jane Doe',
2202
+ email: 'jane@example.com',
2203
+ subject: 'Question about shipping',
2204
+ message: 'Hi, do you ship internationally?',
2205
+ phone: '+1-555-0100', // optional
2206
+ customerId: customer?.id, // optional \u2014 link to logged-in customer
2207
+ metadata: { page: '/contact' }, // optional
2208
+ });
2209
+ // \u2192 { id, status: 'NEW', createdAt }
2210
+ \`\`\`
2211
+
2212
+ ### Flexible path \u2014 end-to-end contract
2213
+
2214
+ **Endpoints:**
2215
+
2216
+ | Method | Path | Returns |
2217
+ | ------ | ----------------------------------------------------------------- | ------------------------------ |
2218
+ | GET | \`/stores/:storeId/contact-forms\` | \`ContactFormSummary[]\` |
2219
+ | GET | \`/stores/:storeId/contact-forms/:formKey?locale=xx\` | \`ContactFormPublic\` |
2220
+ | POST | \`/stores/:storeId/inquiries\` | \`CreateInquiryResponse\` |
2221
+
2222
+ All three are public (no API key). The SDK wraps them so you never call them
2223
+ directly \u2014 use \`brainerce.contactForms.list()\`, \`brainerce.contactForms.get()\`,
2224
+ and \`brainerce.createInquiry()\`.
2225
+
2226
+ \`\`\`typescript
2227
+ import type { ContactFormPublic } from 'brainerce';
2228
+
2229
+ // List active forms (optional \u2014 if you want a form picker or route-per-form setup)
2230
+ const forms = await brainerce.contactForms.list();
2231
+ // \u2192 [{ key: 'main', name: 'Contact us', isDefault: true }, ...]
2232
+
2233
+ // Fetch one form's schema \u2014 server pre-resolves translations for the locale
2234
+ // and strips every field the merchant marked isVisible=false.
2235
+ const form = await brainerce.contactForms.get('main', 'en');
2236
+ // \u2192 {
2237
+ // id, key, name, description?, submitButton, successMessage,
2238
+ // fields: [{ key, type, label, placeholder?, helpText?, isRequired,
2239
+ // enumValues?, validation?, defaultValue?, width? }, ...]
2240
+ // }
2241
+
2242
+ // Submit a keyed payload. Unknown keys are stripped, required fields are
2243
+ // enforced, and every value is validated against \`validation\` server-side.
2244
+ await brainerce.createInquiry({
2245
+ formKey: 'main',
2246
+ fields: {
2247
+ email: 'jane@example.com', // built-in keys
2248
+ message: 'Hi...',
2249
+ // ...any custom keys the merchant added via the dashboard
2250
+ },
2251
+ locale: 'en', // stored on the inquiry \u2014 lets staff filter by language
2252
+ sourceMetadata: { page: '/contact' }, // arbitrary provenance (UTM, referrer, etc.)
2253
+ });
2254
+ \`\`\`
2255
+
2256
+ Both shapes go to the same POST endpoint and may be mixed; \`fields\` wins when
2257
+ both provide the same key. Unknown keys (not defined on the form schema) are
2258
+ stripped server-side.
2259
+
2260
+ ### Dynamic rendering \u2014 reference implementation
2261
+
2262
+ Render every field type the merchant can pick. Keep this as a \`<DynamicField>\`
2263
+ component so the form is fully driven by \`schema.fields\`.
2264
+
2265
+ **Layout.** Each field carries an optional \`width\` hint:
2266
+
2267
+ | \`field.width\` | Meaning | Grid style (6-column grid) |
2268
+ | ------------- | ------- | -------------------------- |
2269
+ | \`'FULL'\` (default) | Full row | \`grid-column: span 6\` |
2270
+ | \`'HALF'\` | Half row | \`grid-column: span 3\` |
2271
+ | \`'THIRD'\` | One-third row | \`grid-column: span 2\` |
2272
+
2273
+ Stack fields to full width on small screens (< ~640 px).
2274
+
2275
+ **Required fields.** Show a red asterisk on required labels, validate
2276
+ **client-side** before calling \`createInquiry()\`, and show inline error
2277
+ messages. Do NOT rely only on the server returning 400.
2278
+
2279
+ \`\`\`tsx
2280
+ import type { ContactFormPublic, ContactFormPublicField } from 'brainerce';
2281
+
2282
+ type FieldValue = string | string[] | boolean;
2283
+
2284
+ function defaultValueFor(f: ContactFormPublicField): FieldValue {
2285
+ if (f.type === 'CHECKBOX') return false;
2286
+ if (f.type === 'MULTI_SELECT') return [];
2287
+ return f.defaultValue ?? '';
2288
+ }
2289
+
2290
+ function isEmpty(v: FieldValue): boolean {
2291
+ if (typeof v === 'string') return v.trim().length === 0;
2292
+ if (Array.isArray(v)) return v.length === 0;
2293
+ return v === false;
2294
+ }
2295
+
2296
+ // Map width \u2192 CSS grid column span (6-column grid)
2297
+ function widthClass(w?: string): string {
2298
+ switch (w) {
2299
+ case 'HALF': return 'grid-col-half'; // grid-column: span 3; on sm+, full on mobile
2300
+ case 'THIRD': return 'grid-col-third'; // grid-column: span 2; on sm+, full on mobile
2301
+ default: return 'grid-col-full'; // grid-column: span 6
2302
+ }
2303
+ }
2304
+
2305
+ function DynamicField({
2306
+ field,
2307
+ value,
2308
+ error,
2309
+ onChange,
2310
+ }: {
2311
+ field: ContactFormPublicField;
2312
+ value: FieldValue;
2313
+ error?: string;
2314
+ onChange: (v: FieldValue) => void;
2315
+ }) {
2316
+ const id = \`contact-\${field.key}\`;
2317
+ const { minLength, maxLength, min, max, pattern } = field.validation ?? {};
2318
+ const strVal = typeof value === 'string' ? value : '';
2319
+
2320
+ // Label \u2014 USE \`field.label\`, NEVER hardcode. Falls back to the key.
2321
+ const label = (
2322
+ <label htmlFor={id} className="mb-1.5 block text-sm font-medium">
2323
+ {field.label}
2324
+ {field.isRequired && <span aria-hidden className="text-red-500"> *</span>}
2325
+ </label>
2326
+ );
2327
+ const help = field.helpText ? <p className="mt-1 text-xs opacity-70">{field.helpText}</p> : null;
2328
+ const errorEl = error ? <p className="mt-1 text-xs text-red-500">{error}</p> : null;
2329
+
2330
+ // Outer div uses widthClass to control grid-column span
2331
+ switch (field.type) {
2332
+ case 'TEXTAREA':
2333
+ 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>);
2334
+ case 'EMAIL':
2335
+ 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>);
2336
+ case 'PHONE':
2337
+ 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>);
2338
+ case 'URL':
2339
+ 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>);
2340
+ case 'NUMBER':
2341
+ 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>);
2342
+ case 'DATE':
2343
+ 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>);
2344
+ case 'SELECT':
2345
+ 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>);
2346
+ case 'MULTI_SELECT': {
2347
+ const arr = Array.isArray(value) ? value : [];
2348
+ 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>);
2349
+ }
2350
+ case 'CHECKBOX':
2351
+ 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>);
2352
+ case 'TEXT':
2353
+ default:
2354
+ 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>);
2355
+ }
2356
+ }
2357
+
2358
+ export function ContactPage() {
2359
+ const [schema, setSchema] = useState<ContactFormPublic | null>(null);
2360
+ const [values, setValues] = useState<Record<string, FieldValue>>({});
2361
+ const [errors, setErrors] = useState<Record<string, string>>({});
2362
+ const [honeypot, setHoneypot] = useState('');
2363
+ const [sent, setSent] = useState(false);
2364
+ const [loading, setLoading] = useState(false);
2365
+
2366
+ // Read the current locale from the <html lang> attribute (or your own i18n context).
2367
+ const locale = typeof document !== 'undefined' ? document.documentElement.lang || undefined : undefined;
2368
+
2369
+ useEffect(() => {
2370
+ brainerce.contactForms.get('main', locale).then((form) => {
2371
+ setSchema(form);
2372
+ const initial: Record<string, FieldValue> = {};
2373
+ for (const f of form.fields) initial[f.key] = defaultValueFor(f);
2374
+ setValues(initial);
2375
+ });
2376
+ }, [locale]);
2377
+
2378
+ if (!schema) return null;
2379
+
2380
+ const onSubmit = async (e: React.FormEvent) => {
2381
+ e.preventDefault();
2382
+ if (loading) return;
2383
+ if (honeypot.trim().length > 0) { setSent(true); return; } // bot \u2192 silent success
2384
+
2385
+ // \u2500\u2500 Client-side required-field validation \u2500\u2500
2386
+ const newErrors: Record<string, string> = {};
2387
+ for (const f of schema.fields) {
2388
+ if (f.isRequired && isEmpty(values[f.key] ?? defaultValueFor(f))) {
2389
+ newErrors[f.key] = \`\${f.label} is required\`; // or pull from your i18n system
2390
+ }
2391
+ }
2392
+ if (Object.keys(newErrors).length > 0) {
2393
+ setErrors(newErrors);
2394
+ return; // block submission
2395
+ }
2396
+ setErrors({});
2397
+
2398
+ setLoading(true);
2399
+ try {
2400
+ const payload: Record<string, unknown> = {};
2401
+ for (const f of schema.fields) {
2402
+ const raw = values[f.key];
2403
+ if (isEmpty(raw)) continue;
2404
+ payload[f.key] = typeof raw === 'string' ? raw.trim() : raw;
2405
+ }
2406
+ await brainerce.createInquiry({ formKey: schema.key, fields: payload, locale });
2407
+ setSent(true);
2408
+ } finally {
2409
+ setLoading(false);
2410
+ }
2411
+ };
2412
+
2413
+ if (sent) {
2414
+ // RENDER THE MERCHANT'S success message \u2014 do not hardcode.
2415
+ return <div>{schema.successMessage}</div>;
2416
+ }
2417
+
2418
+ return (
2419
+ <form onSubmit={onSubmit} noValidate>
2420
+ <h1>{schema.name}</h1>
2421
+ {schema.description && <p>{schema.description}</p>}
2422
+
2423
+ {/* Honeypot \u2014 must be visually hidden from humans, not from bots */}
2424
+ <div aria-hidden style={{ position: 'absolute', left: '-10000px', width: 0, height: 0, overflow: 'hidden' }}>
2425
+ <label htmlFor="contact-honeypot">Leave this field empty</label>
2426
+ <input id="contact-honeypot" type="text" tabIndex={-1} autoComplete="off"
2427
+ value={honeypot} onChange={(e) => setHoneypot(e.target.value)} />
2428
+ </div>
2429
+
2430
+ {/* CSS grid \u2014 6 columns on sm+, 1 column on mobile */}
2431
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(6, 1fr)', gap: '1rem' }}>
2432
+ {schema.fields.map((field) => (
2433
+ <DynamicField key={field.key} field={field}
2434
+ value={values[field.key] ?? defaultValueFor(field)}
2435
+ error={errors[field.key]}
2436
+ onChange={(v) => setValues((p) => ({ ...p, [field.key]: v }))} />
2437
+ ))}
2438
+ </div>
2439
+
2440
+ <button type="submit" disabled={loading}>
2441
+ {loading ? '\u2026' : schema.submitButton}
2442
+ </button>
2443
+ </form>
2444
+ );
2445
+ }
2446
+ \`\`\`
2447
+
2448
+ CSS for the grid column helpers (or use the equivalent Tailwind / inline styles):
2449
+
2450
+ \`\`\`css
2451
+ .grid-col-full { grid-column: span 6; }
2452
+ .grid-col-half { grid-column: span 6; }
2453
+ .grid-col-third { grid-column: span 6; }
2454
+
2455
+ @media (min-width: 640px) {
2456
+ .grid-col-half { grid-column: span 3; }
2457
+ .grid-col-third { grid-column: span 2; }
2458
+ }
2459
+ \`\`\`
2460
+
2461
+ ### Rules
2462
+
2463
+ - **Rate limit:** 3 submissions / 60s per IP \u2014 show a friendly "try again later" message on HTTP 429.
2464
+ - **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\`.
2465
+ - **Built-in keys:** \`name\`, \`email\`, \`phone\`, \`subject\`, \`message\` always exist on the default form; the legacy \`createInquiry\` shape keeps working forever.
2466
+ - **Max values:** each field value is capped at 10 000 chars server-side. Validate client-side before submitting using \`field.validation.maxLength\`.
2467
+ - **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.
2468
+ - **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\`.
2469
+ - **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\`.
2470
+ - **Enum values:** SELECT and MULTI_SELECT always return \`enumValues\` (non-empty array). Render from \`enumValues\`, never a hardcoded list.
2471
+ - **Visibility:** the server strips fields with \`isVisible=false\`, so \`schema.fields\` only contains things to render.
2472
+ - **Origin check:** the endpoint validates \`Origin\` against the store's allowed origins. Test from your real storefront URL, not \`file://\` or localhost unless the merchant added it to allowed origins.
2473
+ - **Locale handling:** always pass \`locale\` to \`contactForms.get()\` and to \`createInquiry()\`. The staff inbox filters inquiries by language. Omit to fall back to the store's default language.
2474
+ - **Success message:** render \`schema.successMessage\` as-is. (Variable interpolation like \`{{customerName}}\` is not yet wired; any such placeholders currently render literally. Safe to use plain text.)
2475
+ - **Caching:** the schema GET is safe to cache per \`{storeId, formKey, locale}\`. 60 s feels live without hammering the API.
2476
+
2477
+ ### Multiple forms
2478
+
2479
+ If the merchant set up more than one form (e.g. a \`main\` form on \`/contact\`
2480
+ and a \`newsletter\` form embedded in the footer), render each form from its
2481
+ own \`contactForms.get(key, locale)\` call. The \`formKey\` you pass to
2482
+ \`createInquiry\` must match.`;
1890
2483
  }
1891
2484
  function getSectionByTopic(topic, connectionId, currency) {
1892
2485
  const cid = connectionId || "vc_YOUR_CONNECTION_ID";
@@ -1904,6 +2497,8 @@ function getSectionByTopic(topic, connectionId, currency) {
1904
2497
  return getCheckoutCustomFieldsSection();
1905
2498
  case "payment":
1906
2499
  return getPaymentProvidersSection();
2500
+ case "saved-payment-methods":
2501
+ return getSavedPaymentMethodsSection();
1907
2502
  case "auth":
1908
2503
  return getCustomerAuthSection();
1909
2504
  case "order-confirmation":
@@ -1916,6 +2511,8 @@ function getSectionByTopic(topic, connectionId, currency) {
1916
2511
  return getRecommendationsSection();
1917
2512
  case "product-customization-fields":
1918
2513
  return getProductCustomizationFieldsSection();
2514
+ case "modifier-groups":
2515
+ return getModifierGroupsSection();
1919
2516
  case "tax":
1920
2517
  return getTaxDisplaySection(cur);
1921
2518
  case "i18n":
@@ -1926,6 +2523,8 @@ function getSectionByTopic(topic, connectionId, currency) {
1926
2523
  return getTypeQuickReference();
1927
2524
  case "admin":
1928
2525
  return getAdminApiSection();
2526
+ case "inquiries":
2527
+ return getContactInquiriesSection();
1929
2528
  case "all":
1930
2529
  return [
1931
2530
  "# Brainerce SDK \u2014 full topic dump",
@@ -1994,6 +2593,10 @@ function getSectionByTopic(topic, connectionId, currency) {
1994
2593
  "",
1995
2594
  "---",
1996
2595
  "",
2596
+ getModifierGroupsSection(),
2597
+ "",
2598
+ "---",
2599
+ "",
1997
2600
  getTaxDisplaySection(cur),
1998
2601
  "",
1999
2602
  "---",
@@ -2002,10 +2605,14 @@ function getSectionByTopic(topic, connectionId, currency) {
2002
2605
  "",
2003
2606
  "---",
2004
2607
  "",
2005
- getAdminApiSection()
2608
+ getAdminApiSection(),
2609
+ "",
2610
+ "---",
2611
+ "",
2612
+ getContactInquiriesSection()
2006
2613
  ].join("\n");
2007
2614
  default:
2008
- return `Unknown topic: "${topic}". Available topics: setup, products, cart, checkout, checkout-custom-fields, payment, auth, order-confirmation, inventory, discounts, recommendations, product-customization-fields, tax, i18n, critical-rules, type-reference, admin, all`;
2615
+ return `Unknown topic: "${topic}". Available topics: setup, products, cart, checkout, checkout-custom-fields, payment, auth, order-confirmation, inventory, discounts, recommendations, product-customization-fields, tax, i18n, critical-rules, type-reference, admin, inquiries, all`;
2009
2616
  }
2010
2617
  }
2011
2618
 
@@ -2031,13 +2638,22 @@ var GET_SDK_DOCS_SCHEMA = {
2031
2638
  "type-reference",
2032
2639
  "i18n",
2033
2640
  "admin",
2641
+ "inquiries",
2034
2642
  "all"
2035
2643
  ]).describe("The SDK documentation topic to retrieve"),
2036
- connectionId: import_zod.z.string().optional().describe("Vibe-coded connection ID (starts with vc_). Used to personalize setup code."),
2644
+ salesChannelId: import_zod.z.string().optional().describe("Sales channel ID (starts with vc_). Used to personalize setup code."),
2645
+ /** @deprecated alias of salesChannelId */
2646
+ connectionId: import_zod.z.string().optional().describe("Deprecated alias of salesChannelId \u2014 kept for backwards compat"),
2037
2647
  currency: import_zod.z.string().optional().describe("Store currency code (e.g., USD, ILS, EUR). Used in price formatting examples.")
2038
2648
  };
2039
2649
  async function handleGetSdkDocs(args) {
2040
- const content = getSectionByTopic(args.topic, args.connectionId, args.currency);
2650
+ const id = args.salesChannelId ?? args.connectionId;
2651
+ if (!args.salesChannelId && args.connectionId) {
2652
+ console.warn(
2653
+ "get-sdk-docs: `connectionId` is deprecated \u2014 use `salesChannelId` instead"
2654
+ );
2655
+ }
2656
+ const content = getSectionByTopic(args.topic, id, args.currency);
2041
2657
  return {
2042
2658
  content: [{ type: "text", text: content }]
2043
2659
  };
@@ -2076,7 +2692,7 @@ interface Product {
2076
2692
  attributeId: string;
2077
2693
  attributeOptionId: string;
2078
2694
  platform: string;
2079
- attribute: { id: string; name: string; displayType?: 'DEFAULT' | 'COLOR_SWATCH' | 'IMAGE_SWATCH' } | null;
2695
+ attribute: { id: string; name: string; displayType?: 'DEFAULT' | 'COLOR_SWATCH' | 'IMAGE_SWATCH' | 'MIXED_SWATCH' } | null;
2080
2696
  attributeOption: { id: string; name: string; value?: string | null; swatchColor?: string | null; swatchColor2?: string | null; swatchImageUrl?: string | null } | null;
2081
2697
  }>;
2082
2698
  createdAt: string;
@@ -2085,6 +2701,8 @@ interface Product {
2085
2701
 
2086
2702
  // Use getProductSwatches(product) to get grouped swatch data for storefront rendering.
2087
2703
  // Returns Array<{ attributeName, displayType, options: Array<{ name, swatchColor?, swatchColor2?, swatchImageUrl? }> }>
2704
+ // displayType rendering: COLOR_SWATCH \u2192 color circle (swatchColor/swatchColor2); IMAGE_SWATCH \u2192 image thumbnail (swatchImageUrl);
2705
+ // MIXED_SWATCH \u2192 per-option: render swatchImageUrl if set, else swatchColor if set, else text only.
2088
2706
 
2089
2707
  interface ProductImage {
2090
2708
  url: string;
@@ -2165,6 +2783,10 @@ interface ProductQueryParams {
2165
2783
  tags?: string | string[];
2166
2784
  minPrice?: number;
2167
2785
  maxPrice?: number;
2786
+ // Filter by custom-field (metafield) values. Keys = definition.key,
2787
+ // values = accepted values. Only definitions with filterable=true and
2788
+ // type SELECT/MULTI_SELECT/BOOLEAN are honored. AND across keys, OR within.
2789
+ metafields?: Record<string, string | string[]>;
2168
2790
  sortBy?: 'name' | 'price' | 'createdAt';
2169
2791
  sortOrder?: 'asc' | 'desc';
2170
2792
  }
@@ -2582,6 +3204,36 @@ interface PaymentStatus {
2582
3204
  error?: string;
2583
3205
  }
2584
3206
 
3207
+ // ---- Saved Payment Methods (vaulted cards) ----
3208
+ // Display-only summary of a customer's vaulted payment method. The
3209
+ // underlying provider token is encrypted at rest in the platform DB
3210
+ // and NEVER returned through the SDK.
3211
+ //
3212
+ // To opt into vaulting at checkout, pass saveCard: true to
3213
+ // createPaymentIntent (only honored for logged-in customers).
3214
+ //
3215
+ // To list / remove saved methods, see:
3216
+ // client.listSavedPaymentMethods(storeId, customerId)
3217
+ // client.removeSavedPaymentMethod(storeId, customerId, methodId)
3218
+
3219
+ interface SavedPaymentMethodSummary {
3220
+ id: string;
3221
+ customerId: string;
3222
+ appInstallationId: string;
3223
+ paymentMethod: string; // 'credit_card' | 'paypal' | 'bank_account'
3224
+ brand: string | null;
3225
+ last4: string | null;
3226
+ expMonth: number | null;
3227
+ expYear: number | null;
3228
+ isDefault: boolean;
3229
+ status: string; // 'active' | 'expired' | 'invalid'
3230
+ failureReason: string | null;
3231
+ lastUsedAt: string | null;
3232
+ expiresAt: string | null;
3233
+ createdAt: string;
3234
+ updatedAt: string;
3235
+ }
3236
+
2585
3237
  interface WaitForOrderResult {
2586
3238
  success: boolean;
2587
3239
  status: PaymentStatus; // Access: result.status.orderNumber
@@ -2681,16 +3333,267 @@ interface CartUpgradeSuggestion {
2681
3333
  priceDelta: string;
2682
3334
  deltaPercent: number;
2683
3335
  }
2684
- interface CartBundleOffer {
3336
+ interface CartBundleOfferOfferedProduct {
2685
3337
  id: string;
2686
- bundleProduct: ProductRecommendation;
3338
+ name: string;
3339
+ slug: string | null;
3340
+ basePrice: string;
3341
+ salePrice: string | null;
3342
+ images: Array<{ url: string }>;
3343
+ type: string;
2687
3344
  originalPrice: string;
2688
3345
  discountedPrice: string;
3346
+ }
3347
+ interface CartBundleOffer {
3348
+ id: string;
3349
+ name: string;
3350
+ description: string | null;
3351
+ // productIds[0] = trigger product (must be in cart for the bundle to surface);
3352
+ // productIds[1..] = offered together at the bundle discount.
3353
+ triggerProductId: string;
3354
+ productIds: string[];
3355
+ // offeredProducts = productIds[1..] minus those already in cart, each with
3356
+ // its own original/discounted price applied.
3357
+ offeredProducts: CartBundleOfferOfferedProduct[];
2689
3358
  discountType: 'PERCENTAGE' | 'FIXED_AMOUNT';
2690
- discountValue: number;
2691
- requiresVariantSelection: boolean;
2692
- lockedVariant?: { id: string; name?: string };
3359
+ discountValue: string;
3360
+ totalOriginalPrice: string;
3361
+ totalDiscountedPrice: string;
2693
3362
  }`;
3363
+ var INQUIRIES_TYPES = `// ---- Contact Inquiries & Forms ----
3364
+
3365
+ // Two shapes \u2014 legacy and flexible. The two may be mixed; \`fields\` wins on key collision.
3366
+ interface CreateInquiryInput {
3367
+ // Legacy shape (still supported forever, backed by default "main" form)
3368
+ name?: string; // max 120 chars (if provided)
3369
+ email?: string; // must be valid email if provided
3370
+ subject?: string; // max 200 chars
3371
+ message?: string; // max 10000 chars
3372
+ phone?: string;
3373
+
3374
+ // Flexible shape
3375
+ formKey?: string; // defaults to "main"; merchant-defined keys e.g. "newsletter"
3376
+ fields?: Record<string, unknown>; // bag of values keyed by field key (built-in or custom)
3377
+ locale?: string; // e.g. "en", "he" \u2014 stored on the inquiry
3378
+ sourceMetadata?: Record<string, unknown>; // arbitrary context (e.g. { page: '/contact', campaign: 'fall-2026' })
3379
+
3380
+ // Shared
3381
+ customerId?: string; // link to logged-in customer
3382
+ metadata?: Record<string, unknown>; // deprecated alias of sourceMetadata
3383
+ }
3384
+
3385
+ interface CreateInquiryResponse {
3386
+ id: string;
3387
+ status: 'NEW';
3388
+ createdAt: string; // ISO datetime
3389
+ }
3390
+
3391
+ // ---- Form schema (for dynamic rendering) ----
3392
+
3393
+ type ContactFormFieldType =
3394
+ | 'TEXT' | 'TEXTAREA' | 'EMAIL' | 'PHONE' | 'NUMBER'
3395
+ | 'SELECT' | 'MULTI_SELECT' | 'CHECKBOX' | 'URL' | 'DATE';
3396
+
3397
+ interface ContactFormFieldValidation {
3398
+ minLength?: number;
3399
+ maxLength?: number;
3400
+ min?: number;
3401
+ max?: number;
3402
+ pattern?: string; // regex string
3403
+ patternMessage?: string;
3404
+ }
3405
+
3406
+ interface ContactFormPublicField {
3407
+ key: string; // stable identifier, e.g. "email", "company"
3408
+ type: ContactFormFieldType;
3409
+ label: string; // already localized for the requested locale
3410
+ placeholder?: string; // already localized
3411
+ helpText?: string; // already localized
3412
+ isRequired: boolean;
3413
+ enumValues?: { value: string; label: string }[]; // present (non-empty) for SELECT / MULTI_SELECT
3414
+ validation?: ContactFormFieldValidation;
3415
+ defaultValue?: string;
3416
+ width?: 'FULL' | 'HALF' | 'THIRD'; // layout hint \u2014 FULL = full row, HALF = half row, THIRD = one-third row
3417
+ }
3418
+
3419
+ interface ContactFormPublic {
3420
+ id: string;
3421
+ key: string; // e.g. "main", "newsletter"
3422
+ name: string; // already localized \u2014 use as form heading
3423
+ description?: string; // already localized \u2014 use as subtitle
3424
+ submitButton: string; // already localized \u2014 use as submit label
3425
+ successMessage: string; // already localized \u2014 render after submit succeeds
3426
+ fields: ContactFormPublicField[]; // in display order; hidden fields already filtered out
3427
+ }
3428
+
3429
+ interface ContactFormSummary {
3430
+ key: string;
3431
+ name: string;
3432
+ isDefault: boolean;
3433
+ }
3434
+
3435
+ // Field-type \u2192 HTML mapping for dynamic rendering
3436
+ // ------------------------------------------------------------------
3437
+ // TEXT \u2192 <input type="text" ... pattern?={validation.pattern}>
3438
+ // TEXTAREA \u2192 <textarea rows={6} ...>
3439
+ // EMAIL \u2192 <input type="email" autoComplete="email" ...>
3440
+ // PHONE \u2192 <input type="tel" autoComplete="tel" ...>
3441
+ // NUMBER \u2192 <input type="number" min={validation.min} max={validation.max}>
3442
+ // URL \u2192 <input type="url" ...>
3443
+ // DATE \u2192 <input type="date" ...> (value is ISO yyyy-MM-dd)
3444
+ // SELECT \u2192 <select>{enumValues.map(...)}</select> \u2014 always rendered from enumValues
3445
+ // MULTI_SELECT \u2192 multiple <input type="checkbox">, value is string[]
3446
+ // CHECKBOX \u2192 single <input type="checkbox">, value is boolean
3447
+ // ------------------------------------------------------------------
3448
+ //
3449
+ // Layout: render fields inside a CSS grid container.
3450
+ // width='FULL' (default) \u2192 span entire row
3451
+ // width='HALF' \u2192 span half the row (two HALF fields sit side-by-side)
3452
+ // width='THIRD' \u2192 span one-third of the row (three THIRD fields sit side-by-side)
3453
+ // Use a 6-column grid for clean divisibility:
3454
+ // FULL \u2192 col-span-6 | HALF \u2192 col-span-3 | THIRD \u2192 col-span-2
3455
+ // Stack to full width on small screens (< sm breakpoint).
3456
+ //
3457
+ // Required fields: show a red asterisk (*) next to the label, validate
3458
+ // client-side before submission, and display inline error messages for
3459
+ // any empty required field. Do NOT rely only on the server returning 400.
3460
+ // ------------------------------------------------------------------
3461
+
3462
+ // SDK methods
3463
+ // await brainerce.createInquiry(input) \u2192 POST /stores/{storeId}/inquiries
3464
+ // await brainerce.contactForms.list() \u2192 GET /stores/{storeId}/contact-forms
3465
+ // await brainerce.contactForms.get(key?, locale?) \u2192 GET /stores/{storeId}/contact-forms/{key}?locale={locale}
3466
+ //
3467
+ // Rules
3468
+ // - Rate limit: 3 submissions / 60s per IP \u2014 handle 429 responses gracefully
3469
+ // - Honeypot: always render an invisible field named \`honeypot\` and never send it
3470
+ // - Always pass \`locale\` \u2014 inbox filters inquiries by language, and schema labels come back translated
3471
+ // - Render \`schema.name\` / \`description\` / \`submitButton\` / \`successMessage\` directly \u2014 do NOT hardcode copy
3472
+ // - Unknown field keys are stripped server-side \u2014 safe to send extras during dev`;
3473
+ var MODIFIER_GROUPS_TYPES = `// ---- Modifier Groups (Restaurant / Build-Your-Own) ----
3474
+ // Modifier groups are merchant-defined option blocks attached to a product
3475
+ // (toppings, sauce, bread type, \u2026). They differ from product.customizationFields:
3476
+ // modifier groups are STRUCTURED priced choices validated server-side, while
3477
+ // customizationFields are arbitrary buyer input (text/photo/color).
3478
+ //
3479
+ // Money fields are decimal STRINGS on the wire \u2014 "5.00", "-2.00" (downsell).
3480
+ // Never JSON Number. Use parseFloat() only at display time. Do not compute
3481
+ // the line total client-side; the server runs free-allocation and returns
3482
+ // cart.items[i].unitPrice + .modifiers[] + .modifiersTotal.
3483
+
3484
+ export type ModifierSelectionType = 'SINGLE' | 'MULTIPLE';
3485
+
3486
+ export type FreeAllocationPolicy = 'EXPENSIVE_FREE' | 'CHEAPEST_FREE' | 'SELECTION_ORDER';
3487
+
3488
+ export interface Modifier {
3489
+ id: string;
3490
+ name: string;
3491
+ description?: string;
3492
+ /** Decimal string. Negative values = downsell modifiers ("-2.00"). */
3493
+ priceDelta: string;
3494
+ sku?: string;
3495
+ image?: { url: string; thumbnailUrl?: string; alt?: string };
3496
+ position: number;
3497
+ /** Pre-checked on first render. */
3498
+ isDefault: boolean;
3499
+ /** false = sold out \u2014 disable in UI with a "Sold out" badge. */
3500
+ available: boolean;
3501
+ /** When true, never applied as a free selection \u2014 always charges priceDelta even when freeQuantity > 0 on the group. */
3502
+ excludeFromFree?: boolean;
3503
+ /** Nested combo: opens a sub-flow; depth \u2264 3 enforced server-side. */
3504
+ referencedProductId?: string;
3505
+ translations?: Record<string, { name?: string; description?: string }>;
3506
+ }
3507
+
3508
+ export interface ModifierGroup {
3509
+ id: string;
3510
+ /**
3511
+ * Set when fetched in a product context (the ProductModifierGroup row id).
3512
+ * Use this to update or detach the attachment without reattaching the group.
3513
+ */
3514
+ attachmentId?: string;
3515
+ /** Customer-facing canonical name. */
3516
+ name: string;
3517
+ /**
3518
+ * Admin-only disambiguator \u2014 NEVER present in storefront responses.
3519
+ * If your client code reads this, you're hitting an admin endpoint by mistake.
3520
+ */
3521
+ internalName?: string;
3522
+ description?: string;
3523
+ selectionType: ModifierSelectionType;
3524
+ /** Effective minimum after any per-attach / per-variant overrides. */
3525
+ min: number;
3526
+ /** Effective maximum; null = unlimited. **0 = group hidden for this variant** (PRD \xA77.2.2). */
3527
+ max?: number | null;
3528
+ freeQuantity: number;
3529
+ required: boolean;
3530
+ freeAllocationPolicy: FreeAllocationPolicy;
3531
+ modifiers: Modifier[];
3532
+ /** Effective default selections (after attachment-level overrides). */
3533
+ defaultModifierIds: string[];
3534
+ translations?: Record<string, { name?: string; description?: string }>;
3535
+ }
3536
+
3537
+ /** Customer-side selection payload \u2014 modifierIds in click-order. */
3538
+ export interface ModifierSelection {
3539
+ modifierGroupId: string;
3540
+ modifierIds: string[];
3541
+ }
3542
+
3543
+ /** Per-line modifier breakdown surfaced on cart.items[i] / order.items[i]. */
3544
+ export interface CartItemModifierLine {
3545
+ modifierId: string;
3546
+ /** Snapshot of the modifier's name at the time the line was added. */
3547
+ name: string;
3548
+ /** Decimal string snapshot of the priceDelta at the time the line was added. */
3549
+ priceDelta: string;
3550
+ /** True if this modifier consumed one of the group's free slots. */
3551
+ freeApplied: boolean;
3552
+ }
3553
+
3554
+ /**
3555
+ * Stable error codes returned in the structured 400 envelope when a cart
3556
+ * payload fails server-side validation. The SDK exposes the envelope on
3557
+ * BrainerceError.details \u2014 switch on details.code === 'MODIFIER_VALIDATION_FAILED'
3558
+ * first, then iterate details.errors[].
3559
+ */
3560
+ export type ModifierValidationCode =
3561
+ | 'REQUIRED_GROUP_MISSING'
3562
+ | 'MIN_SELECTIONS_NOT_MET'
3563
+ | 'MAX_SELECTIONS_EXCEEDED'
3564
+ | 'SINGLE_GROUP_MULTIPLE_PICKS'
3565
+ | 'UNKNOWN_MODIFIER'
3566
+ | 'UNKNOWN_GROUP'
3567
+ | 'MODIFIER_DISABLED_FOR_VARIANT'
3568
+ | 'MODIFIER_NOT_AVAILABLE'
3569
+ | 'NESTED_DEPTH_EXCEEDED'
3570
+ | 'NESTED_REQUIRES_PRODUCT_REF'
3571
+ | 'INVALID_PRICE_DELTA'
3572
+ | 'MODIFIER_PRICE_FLOOR_VIOLATED';
3573
+
3574
+ export interface ModifierValidationError {
3575
+ code: ModifierValidationCode;
3576
+ message: string;
3577
+ modifierGroupId?: string;
3578
+ modifierId?: string;
3579
+ }
3580
+
3581
+ // Cart DTOs gain optional selections + nestedByModifierId \u2014 see CART_TYPES above.
3582
+ // Add to cart with selections:
3583
+ //
3584
+ // await client.smartAddToCart({
3585
+ // productId,
3586
+ // variantId,
3587
+ // quantity: 1,
3588
+ // selections: [
3589
+ // { modifierGroupId: 'mg_bread', modifierIds: ['m_thick'] },
3590
+ // { modifierGroupId: 'mg_toppings', modifierIds: ['m_olive', 'm_bacon'] },
3591
+ // ],
3592
+ // });
3593
+ //
3594
+ // The cart line then carries:
3595
+ // cart.items[i].modifiers \u2014 CartItemModifierLine[]
3596
+ // cart.items[i].modifiersTotal \u2014 decimal string of the paid (non-free) deltas`;
2694
3597
  var TYPES_BY_DOMAIN = {
2695
3598
  products: PRODUCTS_TYPES,
2696
3599
  cart: CART_TYPES,
@@ -2698,7 +3601,9 @@ var TYPES_BY_DOMAIN = {
2698
3601
  orders: ORDERS_TYPES,
2699
3602
  customers: CUSTOMERS_TYPES,
2700
3603
  payments: PAYMENTS_TYPES,
2701
- helpers: HELPERS_TYPES
3604
+ helpers: HELPERS_TYPES,
3605
+ inquiries: INQUIRIES_TYPES,
3606
+ "modifier-groups": MODIFIER_GROUPS_TYPES
2702
3607
  };
2703
3608
  function getTypesByDomain(domain) {
2704
3609
  if (domain === "all") {
@@ -2719,7 +3624,17 @@ var AVAILABLE_DOMAINS = Object.keys(TYPES_BY_DOMAIN);
2719
3624
  var GET_TYPE_DEFINITIONS_NAME = "get-type-definitions";
2720
3625
  var GET_TYPE_DEFINITIONS_DESCRIPTION = "Get TypeScript type definitions from the Brainerce SDK, segmented by domain. Use this when you need to understand the exact shape of objects like Product, Cart, Checkout, Order, etc.";
2721
3626
  var GET_TYPE_DEFINITIONS_SCHEMA = {
2722
- domain: import_zod2.z.enum(["products", "cart", "checkout", "orders", "customers", "payments", "helpers", "all"]).describe(
3627
+ domain: import_zod2.z.enum([
3628
+ "products",
3629
+ "cart",
3630
+ "checkout",
3631
+ "orders",
3632
+ "customers",
3633
+ "payments",
3634
+ "helpers",
3635
+ "inquiries",
3636
+ "all"
3637
+ ]).describe(
2723
3638
  'The domain of types to retrieve. Use "helpers" for helper function signatures and common types like StoreInfo, PaginatedResponse.'
2724
3639
  )
2725
3640
  };
@@ -2754,7 +3669,12 @@ var GET_CODE_EXAMPLE_SCHEMA = {
2754
3669
  "reservation-countdown",
2755
3670
  "search-autocomplete-debounce",
2756
3671
  "i18n-set-locale",
2757
- "order-history-full"
3672
+ "order-history-full",
3673
+ "modifier-groups-render",
3674
+ "cart-add-with-selections",
3675
+ "modifier-validation-error-handling",
3676
+ "checkout-price-drift",
3677
+ "cart-item-modifier-display"
2758
3678
  ]).describe("The SDK operation to get a snippet for.")
2759
3679
  };
2760
3680
  var SNIPPETS = {
@@ -2762,7 +3682,10 @@ var SNIPPETS = {
2762
3682
  import { BrainerceClient } from 'brainerce';
2763
3683
 
2764
3684
  export const client = new BrainerceClient({
2765
- connectionId: process.env.BRAINERCE_CONNECTION_ID!, // vc_*
3685
+ // Read either env var name (the new one is preferred; the old one is a soft
3686
+ // alias kept for backwards compatibility \u2014 both are accepted by the SDK).
3687
+ salesChannelId:
3688
+ process.env.BRAINERCE_SALES_CHANNEL_ID! ?? process.env.BRAINERCE_CONNECTION_ID!, // vc_*
2766
3689
  });
2767
3690
 
2768
3691
  // If the store has i18n enabled, set the locale at app start based on
@@ -3182,7 +4105,418 @@ for (const order of orders) {
3182
4105
 
3183
4106
  // All sections above are conditional. Absent data = section not rendered \u2014
3184
4107
  // never show empty placeholders. The create-brainerce-store template's
3185
- // src/components/account/order-history.tsx is the reference implementation.`
4108
+ // src/components/account/order-history.tsx is the reference implementation.`,
4109
+ "modifier-groups-render": `// Render modifier groups on the product detail page (toppings / sauce /
4110
+ // build-your-own). The data lives on product.modifierGroups \u2014 only present
4111
+ // for restaurant / customizable products.
4112
+ import { useState } from 'react';
4113
+ import type { ModifierGroup, Product } from 'brainerce';
4114
+
4115
+ function ModifierGroups({ product }: { product: Product }) {
4116
+ const groups: ModifierGroup[] = product.modifierGroups ?? [];
4117
+ if (groups.length === 0) return null; // not a customizable product
4118
+
4119
+ // Local state: selected modifier IDs per group, in click-order.
4120
+ const [selections, setSelections] = useState<Record<string, string[]>>(() =>
4121
+ buildInitialSelections(groups)
4122
+ );
4123
+
4124
+ return (
4125
+ <>
4126
+ {groups.map((group) => {
4127
+ // PRD \xA77.2.2: max === 0 means "hidden for this variant" \u2014 skip entirely.
4128
+ if (group.max === 0) return null;
4129
+
4130
+ const isSingle = group.selectionType === 'SINGLE';
4131
+ const inputType = isSingle ? 'radio' : 'checkbox';
4132
+ const sorted = [...group.modifiers].sort((a, b) => a.position - b.position);
4133
+ const picks = selections[group.id] ?? [];
4134
+ const usedFree = Math.min(picks.length, group.freeQuantity);
4135
+
4136
+ const toggle = (modifierId: string, checked: boolean) => {
4137
+ setSelections((prev) => {
4138
+ const cur = prev[group.id] ?? [];
4139
+ if (isSingle) return { ...prev, [group.id]: checked ? [modifierId] : [] };
4140
+ if (checked) {
4141
+ if (group.max != null && cur.length >= group.max) return prev; // at cap
4142
+ return { ...prev, [group.id]: [...cur, modifierId] };
4143
+ }
4144
+ return { ...prev, [group.id]: cur.filter((id) => id !== modifierId) };
4145
+ });
4146
+ };
4147
+
4148
+ return (
4149
+ <fieldset key={group.id}>
4150
+ <legend>
4151
+ {group.name}{group.required && ' *'}
4152
+ </legend>
4153
+ {group.freeQuantity > 0 && (
4154
+ <p>{usedFree} of {group.freeQuantity} free</p>
4155
+ )}
4156
+ {sorted.map((m) => (
4157
+ <label key={m.id}>
4158
+ <input
4159
+ type={inputType}
4160
+ name={\`mg-\${group.id}\`}
4161
+ value={m.id}
4162
+ checked={picks.includes(m.id)}
4163
+ disabled={!m.available}
4164
+ onChange={(e) => toggle(m.id, e.target.checked)}
4165
+ />
4166
+ {m.name}
4167
+ {parseFloat(m.priceDelta) !== 0 && (
4168
+ <span> {parseFloat(m.priceDelta) > 0 ? '+' : ''}{m.priceDelta}</span>
4169
+ )}
4170
+ {!m.available && <span> (Sold out)</span>}
4171
+ </label>
4172
+ ))}
4173
+ </fieldset>
4174
+ );
4175
+ })}
4176
+ </>
4177
+ );
4178
+ }
4179
+
4180
+ // Initial state: prefer per-attach defaultModifierIds; fall back to
4181
+ // modifier.isDefault flags. Filter sold-out modifiers.
4182
+ function buildInitialSelections(groups: ModifierGroup[]): Record<string, string[]> {
4183
+ const out: Record<string, string[]> = {};
4184
+ for (const group of groups) {
4185
+ if (group.max === 0) continue;
4186
+ const fromAttach = group.defaultModifierIds ?? [];
4187
+ const fromIsDefault = group.modifiers
4188
+ .filter((m) => m.isDefault && m.available)
4189
+ .map((m) => m.id);
4190
+ const merged =
4191
+ fromAttach.length > 0
4192
+ ? fromAttach.filter((id) => group.modifiers.some((m) => m.id === id && m.available))
4193
+ : fromIsDefault;
4194
+ const capped = group.selectionType === 'SINGLE' ? merged.slice(0, 1) : merged;
4195
+ if (capped.length > 0) out[group.id] = capped;
4196
+ }
4197
+ return out;
4198
+ }
4199
+
4200
+ // PRICE-DELTA RULES:
4201
+ // - "5.00" \u2192 +$5 added to unit price
4202
+ // - "-2.00" \u2192 downsell: subtracts $2; never consumes a free slot
4203
+ // - "0.00" \u2192 free option; render no price label
4204
+ // Server enforces unitPrice >= 0; rejects negative deltas combined with
4205
+ // referencedProductId. Do NOT compute the line total client-side \u2014 the
4206
+ // server runs free-allocation and returns cart.items[i].unitPrice.
4207
+
4208
+ `,
4209
+ "cart-add-with-selections": `// Pass modifier-group selections on add-to-cart. The server validates
4210
+ // against the effective rules and computes the final unitPrice (base +
4211
+ // paid modifiers, after the free-allocation policy runs).
4212
+ import { client } from './brainerce';
4213
+ import type { ModifierSelection } from 'brainerce';
4214
+
4215
+ async function addPizzaToCart(
4216
+ productId: string,
4217
+ variantId: string,
4218
+ selectionsByGroup: Record<string, string[]>
4219
+ ) {
4220
+ // Adapter: shape used in component state \u2192 wire format the SDK accepts.
4221
+ const selections: ModifierSelection[] = Object.entries(selectionsByGroup)
4222
+ .filter(([, modifierIds]) => modifierIds.length > 0)
4223
+ .map(([modifierGroupId, modifierIds]) => ({ modifierGroupId, modifierIds }));
4224
+
4225
+ // modifierIds is in CLICK-ORDER \u2014 used by the SELECTION_ORDER free-allocation
4226
+ // policy. Don't sort it; preserve the order the customer clicked.
4227
+
4228
+ const cart = await client.smartAddToCart({
4229
+ productId,
4230
+ variantId,
4231
+ quantity: 1,
4232
+ selections,
4233
+ });
4234
+
4235
+ // Read the snapshot back off the new cart line.
4236
+ const line = cart.items[cart.items.length - 1];
4237
+ // line.modifiers \u2192 CartItemModifierLine[] with { modifierId, name, priceDelta, freeApplied }
4238
+ // line.modifiersTotal \u2192 decimal string of paid (non-free) deltas
4239
+ // line.unitPrice \u2192 final unit price including all paid modifiers
4240
+ console.log('Free applied:', line.modifiers?.filter((m) => m.freeApplied).map((m) => m.name));
4241
+ }
4242
+
4243
+ // EDITING SELECTIONS LATER (PRD \xA77.2.3 \u2014 idempotent replacement):
4244
+ // PATCH the cart item with a fresh selections array. The server deletes ALL
4245
+ // existing CartItemModifier rows and recreates them in the same transaction.
4246
+ // To leave selections untouched (e.g., quantity-only update), omit them.
4247
+ async function changeToppings(cartId: string, itemId: string, newToppingIds: string[]) {
4248
+ await client.updateCartItem(cartId, itemId, {
4249
+ quantity: 1,
4250
+ selections: [{ modifierGroupId: 'mg_toppings', modifierIds: newToppingIds }],
4251
+ });
4252
+ }
4253
+
4254
+ // NESTED COMBOS (depth \u2264 3): when a picked modifier carries
4255
+ // referencedProductId, fetch that product's own modifierGroups, collect the
4256
+ // nested selections, and pass them keyed by the PARENT modifier id.
4257
+ async function addCombo(productId: string, mainBurger: string) {
4258
+ await client.smartAddToCart({
4259
+ productId,
4260
+ quantity: 1,
4261
+ selections: [
4262
+ { modifierGroupId: 'mg_main', modifierIds: [mainBurger] }, // the burger
4263
+ { modifierGroupId: 'mg_drink', modifierIds: ['m_cola'] },
4264
+ ],
4265
+ nestedByModifierId: {
4266
+ [mainBurger]: [
4267
+ { modifierGroupId: 'mg_doneness', modifierIds: ['m_medium'] },
4268
+ { modifierGroupId: 'mg_cheese', modifierIds: ['m_cheddar'] },
4269
+ ],
4270
+ },
4271
+ });
4272
+ }`,
4273
+ "modifier-validation-error-handling": `// Surface MODIFIER_VALIDATION_FAILED to the user. The SDK throws
4274
+ // BrainerceError on HTTP 400; the structured envelope is on .details.
4275
+ import { client } from './brainerce';
4276
+ import type { ModifierSelection } from 'brainerce';
4277
+
4278
+ interface ModifierValidationError {
4279
+ code:
4280
+ | 'REQUIRED_GROUP_MISSING'
4281
+ | 'MIN_SELECTIONS_NOT_MET'
4282
+ | 'MAX_SELECTIONS_EXCEEDED'
4283
+ | 'SINGLE_GROUP_MULTIPLE_PICKS'
4284
+ | 'UNKNOWN_MODIFIER'
4285
+ | 'UNKNOWN_GROUP'
4286
+ | 'MODIFIER_DISABLED_FOR_VARIANT'
4287
+ | 'MODIFIER_NOT_AVAILABLE'
4288
+ | 'NESTED_DEPTH_EXCEEDED'
4289
+ | 'NESTED_REQUIRES_PRODUCT_REF'
4290
+ | 'INVALID_PRICE_DELTA'
4291
+ | 'MODIFIER_PRICE_FLOOR_VIOLATED';
4292
+ message: string;
4293
+ modifierGroupId?: string;
4294
+ modifierId?: string;
4295
+ }
4296
+
4297
+ async function tryAddToCart(productId: string, selections: ModifierSelection[]) {
4298
+ try {
4299
+ await client.smartAddToCart({ productId, quantity: 1, selections });
4300
+ return { ok: true as const };
4301
+ } catch (err) {
4302
+ const e = err as {
4303
+ statusCode?: number;
4304
+ details?: { code?: string; errors?: ModifierValidationError[] };
4305
+ };
4306
+
4307
+ if (e.statusCode === 400 && e.details?.code === 'MODIFIER_VALIDATION_FAILED') {
4308
+ // Surface each issue inline. groupId / modifierId let you highlight the
4309
+ // specific control the customer needs to fix.
4310
+ const issues = e.details.errors ?? [];
4311
+ const messages = issues.map((i) => formatIssue(i));
4312
+ return { ok: false as const, issues, messages };
4313
+ }
4314
+
4315
+ // Some other error (network, 500, etc.) \u2014 re-throw to a generic handler.
4316
+ throw err;
4317
+ }
4318
+ }
4319
+
4320
+ function formatIssue(issue: ModifierValidationError): string {
4321
+ switch (issue.code) {
4322
+ case 'REQUIRED_GROUP_MISSING':
4323
+ return 'Please pick at least one option from this group.';
4324
+ case 'MIN_SELECTIONS_NOT_MET':
4325
+ case 'MAX_SELECTIONS_EXCEEDED':
4326
+ case 'SINGLE_GROUP_MULTIPLE_PICKS':
4327
+ return issue.message; // server's message already says "Pick at least N" / "Pick at most N"
4328
+ case 'UNKNOWN_MODIFIER':
4329
+ case 'UNKNOWN_GROUP':
4330
+ case 'MODIFIER_DISABLED_FOR_VARIANT':
4331
+ case 'MODIFIER_NOT_AVAILABLE':
4332
+ // The catalog moved under us \u2014 refetch the product and ask the customer
4333
+ // to re-pick. These codes mean the option simply isn't valid anymore.
4334
+ return 'This option is no longer available \u2014 please refresh.';
4335
+ case 'NESTED_DEPTH_EXCEEDED':
4336
+ case 'NESTED_REQUIRES_PRODUCT_REF':
4337
+ return issue.message; // bug in your renderer \u2014 never go past 3 levels
4338
+ case 'INVALID_PRICE_DELTA':
4339
+ return issue.message; // shouldn't happen for SDK callers \u2014 server-validated on save
4340
+ case 'MODIFIER_PRICE_FLOOR_VIOLATED':
4341
+ // The server reports a generic message \u2014 internals never leak. Show a
4342
+ // friendly error and let the customer remove a downsell.
4343
+ return 'Cannot apply more discounts on this item.';
4344
+ }
4345
+ }
4346
+
4347
+ // CLIENT-SIDE PRE-FLIGHT (UX):
4348
+ // You can mirror these checks before calling addToCart to give faster
4349
+ // feedback, but the SERVER is authoritative. Always treat the 400 envelope
4350
+ // as the source of truth.
4351
+ function preflightSelections(
4352
+ groups: { id: string; name: string; min: number; max?: number | null; required: boolean; selectionType: 'SINGLE' | 'MULTIPLE'; }[],
4353
+ selections: Record<string, string[]>
4354
+ ): string | null {
4355
+ for (const g of groups) {
4356
+ if (g.max === 0) continue; // disabled-for-variant
4357
+ const picks = selections[g.id] ?? [];
4358
+ if (g.required && picks.length === 0) return \`\${g.name} is required\`;
4359
+ if (picks.length < g.min) return \`Pick at least \${g.min} from \${g.name}\`;
4360
+ if (g.max != null && picks.length > g.max) return \`Pick at most \${g.max} from \${g.name}\`;
4361
+ if (g.selectionType === 'SINGLE' && picks.length > 1) return \`Only one option allowed in \${g.name}\`;
4362
+ }
4363
+ return null;
4364
+ }`,
4365
+ "checkout-price-drift": `// PRICE_DRIFT \u2014 a product's price changed between add-to-cart and checkout.
4366
+ // The server returns HTTP 400 with code "PRICE_DRIFT" when it detects that
4367
+ // the stored unitPrice no longer matches the live price.
4368
+ //
4369
+ // Strategy: catch the error \u2192 show "prices updated" dialog \u2192
4370
+ // call refreshCartSnapshots() to re-snapshot all live prices \u2192
4371
+ // retry createCheckout once.
4372
+
4373
+ import { client } from './brainerce-client';
4374
+
4375
+ interface CheckoutOptions {
4376
+ cartId: string;
4377
+ shippingAddress: object;
4378
+ shippingMethodId: string;
4379
+ paymentProviderId: string;
4380
+ }
4381
+
4382
+ async function createCheckoutSafe(opts: CheckoutOptions) {
4383
+ try {
4384
+ return await client.createCheckout({
4385
+ cartId: opts.cartId,
4386
+ shippingAddress: opts.shippingAddress,
4387
+ shippingMethodId: opts.shippingMethodId,
4388
+ paymentProviderId: opts.paymentProviderId,
4389
+ });
4390
+ } catch (err: unknown) {
4391
+ if (!isApiError(err, 'PRICE_DRIFT')) throw err;
4392
+
4393
+ // Prices changed \u2014 refresh snapshots then retry once.
4394
+ // refreshCartSnapshots updates every cart item's unitPrice to the current
4395
+ // live price (base price + any modifier deltas) so the next checkout call
4396
+ // will pass the drift guard.
4397
+ await client.refreshCartSnapshots(opts.cartId);
4398
+
4399
+ // After refreshing, re-fetch the cart so your UI shows the updated prices
4400
+ // before you retry, giving the customer a chance to review.
4401
+ const updatedCart = await client.getCart(opts.cartId);
4402
+
4403
+ // Signal to the UI layer that prices changed so it can show a dialog.
4404
+ throw Object.assign(new Error('PRICES_UPDATED'), {
4405
+ code: 'PRICES_UPDATED',
4406
+ updatedCart,
4407
+ });
4408
+ }
4409
+ }
4410
+
4411
+ function isApiError(err: unknown, code: string): boolean {
4412
+ return (
4413
+ typeof err === 'object' &&
4414
+ err !== null &&
4415
+ 'code' in err &&
4416
+ (err as { code: string }).code === code
4417
+ );
4418
+ }
4419
+
4420
+ // --- UI integration example (framework-neutral) ---
4421
+ //
4422
+ // async function handlePlaceOrder() {
4423
+ // try {
4424
+ // const checkout = await createCheckoutSafe({ cartId, ... });
4425
+ // router.push(\`/order-confirmation?checkoutId=\${checkout.id}\`);
4426
+ // } catch (err: unknown) {
4427
+ // if (isApiError(err, 'PRICES_UPDATED')) {
4428
+ // const { updatedCart } = err as { updatedCart: Cart };
4429
+ // showPricesUpdatedDialog(updatedCart); // show diff, let user confirm
4430
+ // return;
4431
+ // }
4432
+ // showGenericError(err);
4433
+ // }
4434
+ // }
4435
+ //
4436
+ // The dialog should:
4437
+ // 1. Show which items changed price (compare old vs new unitPrice)
4438
+ // 2. Offer "Continue with new prices" (calls createCheckoutSafe again)
4439
+ // and "Go back to cart" (returns to cart page)
4440
+ // 3. NOT silently retry \u2014 the customer must acknowledge the price change.`,
4441
+ "cart-item-modifier-display": `// Rendering cart items that have modifier selections.
4442
+ // Each cart item has a \`modifiers\` array \u2014 each entry records the modifier
4443
+ // name, the price delta at time of add-to-cart, and whether it was free
4444
+ // (inside the group's freeQuantity allowance).
4445
+ //
4446
+ // Typical display:
4447
+ // Margherita \u20AA45.00
4448
+ // Extra cheese +\u20AA5.00
4449
+ // Mushrooms free
4450
+ // No onions \u2014
4451
+ // \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
4452
+ // Base \u20AA45.00 + Modifiers \u20AA5.00 = \u20AA50.00
4453
+
4454
+ import { formatPrice } from 'brainerce';
4455
+
4456
+ interface CartItemModifier {
4457
+ modifierId: string;
4458
+ name: string;
4459
+ priceDeltaAtTime: number; // in store currency units, e.g. 5.00
4460
+ freeApplied: boolean; // true when inside the group's freeQuantity
4461
+ }
4462
+
4463
+ interface CartItem {
4464
+ id: string;
4465
+ name: string;
4466
+ unitPrice: number; // base + all chargeable modifier deltas, as stored
4467
+ quantity: number;
4468
+ modifiers?: CartItemModifier[];
4469
+ currency: string; // e.g. 'ILS', 'USD'
4470
+ }
4471
+
4472
+ function renderCartItem(item: CartItem): string {
4473
+ const currency = item.currency;
4474
+ const lines: string[] = [];
4475
+
4476
+ lines.push(\`\${item.name} \${formatPrice(item.unitPrice, { currency })}\`);
4477
+
4478
+ const chargeableModifiers = (item.modifiers ?? []).filter(
4479
+ (m) => !m.freeApplied && m.priceDeltaAtTime !== 0
4480
+ );
4481
+ const freeModifiers = (item.modifiers ?? []).filter((m) => m.freeApplied);
4482
+ const zeroModifiers = (item.modifiers ?? []).filter(
4483
+ (m) => !m.freeApplied && m.priceDeltaAtTime === 0
4484
+ );
4485
+
4486
+ for (const m of chargeableModifiers) {
4487
+ const sign = m.priceDeltaAtTime > 0 ? '+' : '';
4488
+ lines.push(\` \${m.name} \${sign}\${formatPrice(m.priceDeltaAtTime, { currency })}\`);
4489
+ }
4490
+ for (const m of freeModifiers) {
4491
+ lines.push(\` \${m.name} free\`);
4492
+ }
4493
+ for (const m of zeroModifiers) {
4494
+ lines.push(\` \${m.name} \u2014\`);
4495
+ }
4496
+
4497
+ // Price breakdown: only show when there are chargeable modifiers
4498
+ const modifiersTotal = chargeableModifiers.reduce(
4499
+ (sum, m) => sum + m.priceDeltaAtTime,
4500
+ 0
4501
+ );
4502
+ if (modifiersTotal !== 0) {
4503
+ // unitPrice already includes modifiers \u2014 derive base for display only
4504
+ const basePrice = item.unitPrice - modifiersTotal;
4505
+ const base = formatPrice(basePrice, { currency }) as string;
4506
+ const mods = formatPrice(modifiersTotal, { currency }) as string;
4507
+ const total = formatPrice(item.unitPrice, { currency }) as string;
4508
+ lines.push(\`Base \${base} + Modifiers \${mods} = \${total}\`);
4509
+ }
4510
+
4511
+ return lines.join('\\n');
4512
+ }
4513
+
4514
+ // IMPORTANT: unitPrice already includes all chargeable modifier deltas.
4515
+ // Do NOT add modifier prices on top of unitPrice when computing line totals \u2014
4516
+ // multiply unitPrice \xD7 quantity directly.
4517
+ function lineTotal(item: CartItem): number {
4518
+ return item.unitPrice * item.quantity;
4519
+ }`
3186
4520
  };
3187
4521
  async function handleGetCodeExample(args) {
3188
4522
  const snippet = SNIPPETS[args.operation];
@@ -3298,13 +4632,27 @@ function getCandidateApiUrls() {
3298
4632
 
3299
4633
  // src/tools/get-store-info.ts
3300
4634
  var GET_STORE_INFO_NAME = "get-store-info";
3301
- 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.";
4635
+ 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.";
3302
4636
  var GET_STORE_INFO_SCHEMA = {
3303
- connectionId: import_zod4.z.string().describe("Vibe-coded connection ID (starts with vc_)")
4637
+ salesChannelId: import_zod4.z.string().optional().describe("Sales channel ID (starts with vc_)"),
4638
+ /** @deprecated alias of salesChannelId */
4639
+ connectionId: import_zod4.z.string().optional().describe("Deprecated alias of salesChannelId \u2014 kept for backwards compat")
3304
4640
  };
3305
4641
  async function handleGetStoreInfo(args) {
4642
+ const id = args.salesChannelId ?? args.connectionId;
4643
+ if (!id) {
4644
+ return {
4645
+ content: [{ type: "text", text: "Error: salesChannelId is required" }],
4646
+ isError: true
4647
+ };
4648
+ }
4649
+ if (!args.salesChannelId && args.connectionId) {
4650
+ console.warn(
4651
+ "get-store-info: `connectionId` is deprecated \u2014 use `salesChannelId` instead"
4652
+ );
4653
+ }
3306
4654
  try {
3307
- const resolved = await resolveStoreInfo(args.connectionId, getCandidateApiUrls());
4655
+ const resolved = await resolveStoreInfo(id, getCandidateApiUrls());
3308
4656
  return {
3309
4657
  content: [
3310
4658
  {
@@ -3317,7 +4665,8 @@ async function handleGetStoreInfo(args) {
3317
4665
  storeName: resolved.info.storeName,
3318
4666
  currency: resolved.info.currency,
3319
4667
  language: resolved.info.language,
3320
- connectionId: args.connectionId,
4668
+ salesChannelId: id,
4669
+ connectionId: id,
3321
4670
  apiBaseUrl: resolved.apiBaseUrl
3322
4671
  },
3323
4672
  null,
@@ -3402,9 +4751,11 @@ async function resolveStoreCapabilities(connectionId, candidateUrls) {
3402
4751
 
3403
4752
  // src/tools/get-store-capabilities.ts
3404
4753
  var GET_STORE_CAPABILITIES_NAME = "get-store-capabilities";
3405
- 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.";
4754
+ 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.";
3406
4755
  var GET_STORE_CAPABILITIES_SCHEMA = {
3407
- connectionId: import_zod5.z.string().describe("Vibe-coded connection ID (starts with vc_)")
4756
+ salesChannelId: import_zod5.z.string().optional().describe("Sales channel ID (starts with vc_)"),
4757
+ /** @deprecated alias of salesChannelId */
4758
+ connectionId: import_zod5.z.string().optional().describe("Deprecated alias of salesChannelId \u2014 kept for backwards compat")
3408
4759
  };
3409
4760
  function formatCapabilities(caps) {
3410
4761
  const lines = [];
@@ -3517,15 +4868,27 @@ function formatCapabilities(caps) {
3517
4868
  }
3518
4869
  if (suggestions.length === 0) {
3519
4870
  suggestions.push(
3520
- "Start by building the required features. Use get-required-features with this connectionId for the checklist."
4871
+ "Start by building the required features. Use get-required-features with this salesChannelId for the checklist."
3521
4872
  );
3522
4873
  }
3523
4874
  suggestions.forEach((s) => lines.push(`- ${s}`));
3524
4875
  return lines.join("\n");
3525
4876
  }
3526
4877
  async function handleGetStoreCapabilities(args) {
4878
+ const id = args.salesChannelId ?? args.connectionId;
4879
+ if (!id) {
4880
+ return {
4881
+ content: [{ type: "text", text: "Error: salesChannelId is required" }],
4882
+ isError: true
4883
+ };
4884
+ }
4885
+ if (!args.salesChannelId && args.connectionId) {
4886
+ console.warn(
4887
+ "get-store-capabilities: `connectionId` is deprecated \u2014 use `salesChannelId` instead"
4888
+ );
4889
+ }
3527
4890
  try {
3528
- const resolved = await resolveStoreCapabilities(args.connectionId, getCandidateApiUrls());
4891
+ const resolved = await resolveStoreCapabilities(id, getCandidateApiUrls());
3529
4892
  return {
3530
4893
  content: [{ type: "text", text: formatCapabilities(resolved.capabilities) }]
3531
4894
  };
@@ -3630,7 +4993,7 @@ var RULES = {
3630
4993
  tokens: {
3631
4994
  title: "Auth tokens & BFF pattern",
3632
4995
  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\`.
3633
- - NEVER put the admin API key (\`brainerce_*\`) in client code. It is a server-only secret. Client code uses \`connectionId\` or storefront endpoints.
4996
+ - 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.
3634
4997
  - 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.
3635
4998
  - 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.`
3636
4999
  },
@@ -3923,18 +5286,20 @@ ${flow.body}`
3923
5286
  // src/tools/get-required-features.ts
3924
5287
  var import_zod9 = require("zod");
3925
5288
  var GET_REQUIRED_FEATURES_NAME = "get-required-features";
3926
- 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.";
5289
+ 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.";
3927
5290
  var GET_REQUIRED_FEATURES_SCHEMA = {
3928
- connectionId: import_zod9.z.string().optional().describe(
3929
- "Vibe-coded connection ID (starts with vc_). Optional \u2014 without it, returns the generic checklist."
3930
- )
5291
+ salesChannelId: import_zod9.z.string().optional().describe(
5292
+ "Sales channel ID (starts with vc_). Optional \u2014 without it, returns the generic checklist."
5293
+ ),
5294
+ /** @deprecated alias of salesChannelId */
5295
+ connectionId: import_zod9.z.string().optional().describe("Deprecated alias of salesChannelId \u2014 kept for backwards compat")
3931
5296
  };
3932
5297
  var FEATURES = [
3933
5298
  {
3934
5299
  id: "browse-products",
3935
5300
  title: "Browse, filter, and search products",
3936
- 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.",
3937
- sdk: "client.getProducts({ page, limit, filters, sort }), client.searchProducts(query)",
5301
+ 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.",
5302
+ sdk: "client.getProducts({ page, limit, filters, sort, metafields }), client.getPublicMetafieldDefinitions(), client.searchProducts(query)",
3938
5303
  mandatory: "mandatory"
3939
5304
  },
3940
5305
  {
@@ -4149,10 +5514,16 @@ function renderCapabilitiesSummary(caps) {
4149
5514
  return lines.join("\n");
4150
5515
  }
4151
5516
  async function handleGetRequiredFeatures(args) {
5517
+ const id = args.salesChannelId ?? args.connectionId;
5518
+ if (!args.salesChannelId && args.connectionId) {
5519
+ console.warn(
5520
+ "get-required-features: `connectionId` is deprecated \u2014 use `salesChannelId` instead"
5521
+ );
5522
+ }
4152
5523
  let caps = null;
4153
- if (args.connectionId) {
5524
+ if (id) {
4154
5525
  try {
4155
- const resolved = await resolveStoreCapabilities(args.connectionId, getCandidateApiUrls());
5526
+ const resolved = await resolveStoreCapabilities(id, getCandidateApiUrls());
4156
5527
  caps = resolved.capabilities;
4157
5528
  } catch {
4158
5529
  caps = null;
@@ -4164,11 +5535,11 @@ async function handleGetRequiredFeatures(args) {
4164
5535
  );
4165
5536
  if (caps) {
4166
5537
  sections.push(renderCapabilitiesSummary(caps));
4167
- } else if (args.connectionId) {
5538
+ } else if (id) {
4168
5539
  sections.push(
4169
5540
  `## Live store capabilities
4170
5541
 
4171
- Could not fetch live capabilities for \`${args.connectionId}\`. Proceeding with the generic checklist. Call \`get-store-capabilities\` separately if you want a targeted checklist.`
5542
+ Could not fetch live capabilities for \`${id}\`. Proceeding with the generic checklist. Call \`get-store-capabilities\` separately if you want a targeted checklist.`
4172
5543
  );
4173
5544
  }
4174
5545
  sections.push("## Features");