@brainerce/mcp-server 3.4.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/bin/stdio.js CHANGED
@@ -265,9 +265,14 @@ function CheckoutPage() {
265
265
  }
266
266
 
267
267
  // Step 4: Create payment intent \u2014 returns provider type!
268
+ // Pass saveCard: true when the customer ticked "save my card for next time" \u2014
269
+ // only honored for logged-in customers (not guests). The vaulted card then
270
+ // appears in client.listSavedPaymentMethods(storeId, customerId) and can be
271
+ // charged off-session via subscription / one-click checkout flows.
268
272
  const paymentIntent = await client.createPaymentIntent(checkoutId, {
269
273
  successUrl: \`\${window.location.origin}/order-confirmation?checkout_id=\${checkoutId}\`,
270
274
  cancelUrl: \`\${window.location.origin}/checkout?error=payment_cancelled\`,
275
+ // saveCard: customerOptedIn, // optional opt-in
271
276
  });
272
277
 
273
278
  setPaymentData({ clientSecret: paymentIntent.clientSecret, provider: paymentIntent.provider, checkoutId });
@@ -388,8 +393,10 @@ function PaymentIframe({ clientSecret, checkoutId }: { clientSecret: string; che
388
393
  }
389
394
  if (data?.type === 'brainerce:redirect' && typeof data.url === 'string') {
390
395
  // Top-level navigation (e.g. Bit). ALWAYS validate against an allowlist
391
- // before navigating \u2014 never trust the URL blindly.
392
- if (isTrustedPaymentUrl(data.url)) { window.top!.location.href = data.url; }
396
+ // before navigating \u2014 never trust the URL blindly. The SDK ships with
397
+ // a maintained list of payment-provider hosts; prefer it over a local
398
+ // copy so 'npm update brainerce' picks up new providers automatically.
399
+ if (isAllowedPaymentUrl(data.url)) { window.top!.location.href = data.url; }
393
400
  }
394
401
  if (data?.type === 'brainerce:payment-complete') {
395
402
  // Payment done \u2014 redirect to confirmation page which verifies server-side
@@ -429,15 +436,10 @@ function PaymentIframe({ clientSecret, checkoutId }: { clientSecret: string; che
429
436
  );
430
437
  }
431
438
 
432
- // Allowlist of origins you trust for top-level payment redirects.
433
- function isTrustedPaymentUrl(url: string): boolean {
434
- try {
435
- const u = new URL(url);
436
- if (u.protocol !== 'https:') return false;
437
- // Add your provider hostnames here. Example: Cardcom for Bit express-pay.
438
- return u.hostname === 'cardcom.solutions' || u.hostname.endsWith('.cardcom.solutions');
439
- } catch { return false; }
440
- }
439
+ // Allowlist check is provided by the SDK \u2014 covers Stripe, PayPal, Cardcom,
440
+ // Meshulam, Grow, CreditGuard, plus Brainerce-hosted embed shells. To extend
441
+ // for a self-hosted PSP, pass { extraHosts: ['my-psp.example.com'] }.
442
+ import { isAllowedPaymentUrl } from 'brainerce';
441
443
  \`\`\`
442
444
 
443
445
  ### PayPal Payment Form
@@ -725,6 +727,80 @@ Provider-specific install notes:
725
727
  - **Grow:** No SDK needed \u2014 JS SDK loaded via \`clientSdk.scriptUrl\`. Supports credit cards, Bit, Apple Pay, Google Pay.
726
728
  - **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.`;
727
729
  }
730
+ function getSavedPaymentMethodsSection() {
731
+ return `## Saved Payment Methods (vaulted cards)
732
+
733
+ When a logged-in customer ticks "save my card for next time", the platform vaults the
734
+ card with the underlying provider and stores a reference. Vaulted cards can later be
735
+ charged off-session \u2014 typically via subscription / one-click checkout features built
736
+ on top of this foundation.
737
+
738
+ ### Opt-in at checkout
739
+
740
+ Pass \`saveCard: true\` to \`createPaymentIntent\` when the customer chose to save.
741
+ **Only honored for logged-in customers** \u2014 anonymous (guest) checkouts silently
742
+ skip vaulting because there's no Customer record to attach the resulting saved
743
+ method to.
744
+
745
+ \`\`\`typescript
746
+ const intent = await client.createPaymentIntent(checkoutId, {
747
+ successUrl,
748
+ cancelUrl,
749
+ saveCard: true, // optional \u2014 only when customer ticked the box AND is logged in
750
+ });
751
+ \`\`\`
752
+
753
+ After a successful charge, the saved card appears in the customer's profile.
754
+
755
+ ### Listing saved cards (storefront / customer account page)
756
+
757
+ Show the customer their saved cards on a "Manage Payment Methods" page in their
758
+ account. The platform returns display-only metadata \u2014 last4, brand, expiry,
759
+ default flag, status. The underlying provider token is encrypted at rest and
760
+ NEVER returned through the SDK.
761
+
762
+ \`\`\`typescript
763
+ const methods = await client.listSavedPaymentMethods(storeId, customerId);
764
+
765
+ methods.forEach((m) => {
766
+ console.log(\`\${m.brand} ending in \${m.last4} (expires \${m.expMonth}/\${m.expYear})\`);
767
+ if (m.isDefault) console.log(' \u21B3 default');
768
+ if (m.status === 'expired') console.log(' \u21B3 expired \u2014 please add a new card');
769
+ });
770
+ \`\`\`
771
+
772
+ ### Removing a saved card
773
+
774
+ \`\`\`typescript
775
+ await client.removeSavedPaymentMethod(storeId, customerId, methodId);
776
+ \`\`\`
777
+
778
+ Hard-deletes the row from the platform DB. The provider may still hold the
779
+ underlying token internally \u2014 we don't issue a delete-at-provider call because
780
+ not every provider supports it. From the platform's perspective the token is
781
+ gone; subsequent charges fail.
782
+
783
+ ### Provider support matrix
784
+
785
+ | Provider | Save card on first charge | Charge saved card off-session |
786
+ |---|---|---|
787
+ | Cardcom | \u2705 (Operation: ChargeAndCreateToken) | \u2705 |
788
+ | PayPal | \u2705 (Vault API: store_in_vault) | \u2705 |
789
+ | Stripe | (separate workstream \u2014 Brainerce doesn't ship a Stripe payment app yet) | \u2014 |
790
+ | Grow | \u274C \u2014 returns 501 \`token_storage_unavailable\` | \u274C |
791
+
792
+ When a provider doesn't support tokenization, the platform surfaces a 409 with
793
+ \`code: 'token_storage_unavailable'\`. Storefronts should hide the "save card"
794
+ checkbox when the active provider doesn't support it.
795
+
796
+ ### Charging off-session
797
+
798
+ Charging a saved card from the storefront is **not** part of this foundation \u2014
799
+ that's the job of the Subscription / one-click checkout features built on top.
800
+ For now, charges run through the standard \`createPaymentIntent\` flow. The
801
+ saved-method foundation makes those features possible without further platform
802
+ changes.`;
803
+ }
728
804
  function getProductsSection(_currency) {
729
805
  return `## Products & Variants
730
806
 
@@ -745,6 +821,18 @@ const filtered = await client.getProducts({
745
821
  categories: ['cat_123'], minPrice: 10, maxPrice: 100,
746
822
  sortBy: 'price', sortOrder: 'asc',
747
823
  });
824
+
825
+ // Filter by custom fields (metafields). Only fields the merchant marked
826
+ // \`filterable: true\` are honored; supported types are SELECT, MULTI_SELECT,
827
+ // BOOLEAN. AND across keys, OR within a key.
828
+ const byCustom = await client.getProducts({
829
+ metafields: { color: ['red', 'blue'], in_stock: ['true'] },
830
+ });
831
+
832
+ // Discover which custom fields are filterable for the current store:
833
+ const { definitions } = await client.getPublicMetafieldDefinitions();
834
+ const facets = definitions.filter(d => d.filterable);
835
+ // Render a checkbox group per SELECT/MULTI_SELECT, a switch per BOOLEAN.
748
836
  \`\`\`
749
837
 
750
838
  ### i18n \u2014 translated fields come back on every request
@@ -1114,22 +1202,24 @@ const { upgrades } = await client.getCartUpgrades(cartId);
1114
1202
  // Show inline banner per cart item if upgrade exists
1115
1203
  \`\`\`
1116
1204
 
1117
- **Bundle offers** (discounted complementary products configured by the store owner):
1205
+ **Bundle offers** (N-product bundles configured by the store owner \u2014 productIds[0] triggers the offer, productIds[1..] are offered together at a discount):
1118
1206
  \`\`\`typescript
1119
1207
  import type { CartBundlesResponse } from 'brainerce';
1120
1208
  const { bundles } = await client.getCartBundles(cartId);
1121
- // bundles[].bundleProduct \u2014 the product to offer
1122
- // bundles[].originalPrice / bundles[].discountedPrice \u2014 prices
1209
+ // bundles[].triggerProductId \u2014 already in cart, activates the offer
1210
+ // bundles[].productIds \u2014 full bundle composition (length >= 2)
1211
+ // bundles[].offeredProducts[] \u2014 products customer hasn't added yet
1212
+ // each with originalPrice + discountedPrice
1213
+ // bundles[].totalOriginalPrice / totalDiscountedPrice \u2014 sums across offeredProducts
1123
1214
  // bundles[].discountType ('PERCENTAGE' | 'FIXED_AMOUNT') + discountValue
1124
- // bundles[].requiresVariantSelection \u2014 true if customer must pick a variant
1125
- // bundles[].lockedVariant \u2014 set if admin pre-selected a specific variant
1126
- // bundles[].bundleProduct.variants \u2014 available variants (only when requiresVariantSelection)
1127
1215
 
1128
- // Add a bundle to cart (applies the discount automatically):
1216
+ // Accept a bundle (adds every offered product not yet in cart at the discount):
1129
1217
  await client.addBundleToCart(cartId, bundleOfferId);
1130
- // For products with variants \u2014 pass the selected variantId:
1131
- await client.addBundleToCart(cartId, bundleOfferId, selectedVariantId);
1132
- // Remove a bundle from cart:
1218
+ // If some offered products have variants, pass per-product variant selections:
1219
+ await client.addBundleToCart(cartId, bundleOfferId, {
1220
+ [variantProductId]: selectedVariantId,
1221
+ });
1222
+ // Remove an accepted bundle (removes every cart item linked to that bundle):
1133
1223
  await client.removeBundleFromCart(cartId, bundleOfferId);
1134
1224
  // Detect already-added bundles: check cart.items for metadata?.isBundleItem === true
1135
1225
  \`\`\`
@@ -1185,11 +1275,12 @@ Always check the flag before rendering: \`if (storeInfo?.upsell?.featureName !==
1185
1275
  | CartRecommendationSection | products/ | Cross-sell grid at bottom of cart page |
1186
1276
  | FreeShippingBar | cart/ | Progress bar toward free shipping threshold |
1187
1277
  | CartUpgradeBanner | cart/ | Inline "Upgrade to X for +$Y" per cart item |
1188
- | CartBundleOfferCard | cart/ | Discounted bundle offer card in cart (with inline variant selector for variable products) |
1278
+ | CartBundleOfferCard | cart/ | N-product bundle offer card in cart \u2014 lists every offered product with its discounted price and an "Add bundle" button |
1189
1279
  | OrderBumpCard | checkout/ | Checkbox add-on card in checkout sidebar (with inline variant selector for variable products) |
1190
1280
 
1191
1281
  ProductRecommendation: \`id\`, \`name\`, \`slug\`, \`basePrice\`, \`salePrice\`, \`images\`, \`type\`, \`inventory\`, \`relationType\`, \`variants?\` (when variant selection needed).
1192
- OrderBump/CartBundleOffer: includes \`requiresVariantSelection\` (boolean) and \`lockedVariant?\` (\`{ id, name, attributes }\`).`;
1282
+ OrderBump: includes \`requiresVariantSelection\` (boolean) and \`lockedVariant?\` (\`{ id, name, attributes }\`).
1283
+ 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\`.`;
1193
1284
  }
1194
1285
  function getProductCustomizationFieldsSection() {
1195
1286
  return `## Product Customization Fields (buyer input on product page)
@@ -1295,6 +1386,157 @@ When the order is created, each line's customization values are snapshotted onto
1295
1386
  - Using a raw external URL (not from \`/customization-upload\`) for \`IMAGE\` / \`GALLERY\` \u2192 HTTP 400
1296
1387
  - Assuming \`enumValues\` is present for all types \u2014 only \`SELECT\` / \`MULTI_SELECT\` require it`;
1297
1388
  }
1389
+ function getModifierGroupsSection() {
1390
+ return `## Modifier Groups (toppings, sauce, build-your-own)
1391
+
1392
+ 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.
1393
+
1394
+ If \`product.modifierGroups\` is empty or missing, render the product page normally and skip everything below.
1395
+
1396
+ ### Wire shape on \`GET /products/:id\`
1397
+
1398
+ \`\`\`typescript
1399
+ import type { Product, ModifierGroup, Modifier, ModifierSelection } from 'brainerce';
1400
+
1401
+ const product = await client.getProductBySlug(slug);
1402
+ const groups: ModifierGroup[] = product.modifierGroups ?? [];
1403
+
1404
+ // Each group looks like:
1405
+ // {
1406
+ // id: 'mg_toppings',
1407
+ // attachmentId: 'pmg_01', // ProductModifierGroup row \u2014 used for attach updates
1408
+ // name: 'Toppings', // customer-facing
1409
+ // internalName?: string, // ADMIN ONLY \u2014 never present in storefront responses
1410
+ // selectionType: 'SINGLE' | 'MULTIPLE',
1411
+ // min: number, // effective (overrides already applied)
1412
+ // max: number | null, // null = unlimited; **0 = group hidden for this variant**
1413
+ // freeQuantity: number, // first N picks at no extra cost
1414
+ // required: boolean,
1415
+ // freeAllocationPolicy: 'EXPENSIVE_FREE' | 'CHEAPEST_FREE' | 'SELECTION_ORDER',
1416
+ // defaultModifierIds: string[], // pre-checked on first render
1417
+ // modifiers: Array<{
1418
+ // id: 'm_olive',
1419
+ // name: 'Olives',
1420
+ // priceDelta: '5.00', // DECIMAL STRING \u2014 never JSON Number
1421
+ // position: 0,
1422
+ // isDefault: false, // pre-check this option even if not in defaultModifierIds
1423
+ // available: true, // false \u2192 "sold out", disabled in UI
1424
+ // referencedProductId?: string, // nested-combo target (depth \u2264 3)
1425
+ // }>,
1426
+ // }
1427
+ \`\`\`
1428
+
1429
+ **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.
1430
+
1431
+ ### Render: SINGLE \u2192 radio, MULTIPLE \u2192 checkbox
1432
+
1433
+ Walk \`groups\` and pick the input type by \`selectionType\`. Sort modifiers by \`position\`.
1434
+
1435
+ \`\`\`typescript
1436
+ for (const group of groups) {
1437
+ if (group.max === 0) continue; // disabled-for-variant convention \u2014 skip entirely
1438
+ const inputType = group.selectionType === 'SINGLE' ? 'radio' : 'checkbox';
1439
+ const sorted = [...group.modifiers].sort((a, b) => a.position - b.position);
1440
+ // Render fieldset \u2192 legend with name + " *" if required \u2192 list of sorted options.
1441
+ // Each option: input(type=inputType, name=group.id, value=modifier.id),
1442
+ // disabled when modifier.available === false, with a "Sold out" badge.
1443
+ }
1444
+ \`\`\`
1445
+
1446
+ When \`group.freeQuantity > 0\`, show a running counter so the customer understands the rule:
1447
+ \`\`\`
1448
+ {Math.min(picks.length, group.freeQuantity)} of {group.freeQuantity} free
1449
+ \`\`\`
1450
+
1451
+ 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.
1452
+
1453
+ ### Initial state
1454
+
1455
+ 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.
1456
+
1457
+ ### Pass selections on add-to-cart
1458
+
1459
+ \`\`\`typescript
1460
+ const selections: ModifierSelection[] = [
1461
+ { modifierGroupId: 'mg_bread', modifierIds: ['m_thick'] },
1462
+ { modifierGroupId: 'mg_toppings', modifierIds: ['m_olive', 'm_mushroom', 'm_bacon', 'm_egg'] },
1463
+ ];
1464
+
1465
+ await client.smartAddToCart({
1466
+ productId: product.id,
1467
+ variantId: selectedVariant?.id,
1468
+ quantity: 1,
1469
+ selections,
1470
+ });
1471
+ \`\`\`
1472
+
1473
+ \`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:
1474
+
1475
+ \`\`\`typescript
1476
+ // cart.items[i].modifiers \u2014 same shape as ModifierSelection but with snapshot data
1477
+ [
1478
+ { modifierId: 'm_olive', name: 'Olives', priceDelta: '5.00', freeApplied: true },
1479
+ { modifierId: 'm_mushroom', name: 'Mushrooms', priceDelta: '5.00', freeApplied: true },
1480
+ { modifierId: 'm_bacon', name: 'Bacon', priceDelta: '7.00', freeApplied: true },
1481
+ { modifierId: 'm_egg', name: 'Egg', priceDelta: '6.00', freeApplied: false },
1482
+ ]
1483
+ // + cart.items[i].modifiersTotal \u2014 sum of paid (non-free) deltas as a decimal string
1484
+ \`\`\`
1485
+
1486
+ \`freeApplied: true\` means the modifier consumed a free slot \u2014 render with a "free" badge in the cart UI.
1487
+
1488
+ ### Editing selections after add-to-cart (idempotent)
1489
+
1490
+ \`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).
1491
+
1492
+ \`\`\`typescript
1493
+ await client.updateCartItem(cart.id, itemId, {
1494
+ quantity: 1,
1495
+ selections: [{ modifierGroupId: 'mg_toppings', modifierIds: ['m_olive', 'm_mushroom'] }],
1496
+ });
1497
+ \`\`\`
1498
+
1499
+ ### Validation envelope
1500
+
1501
+ When the payload is invalid the server returns HTTP 400 with a structured envelope. The SDK exposes it on \`BrainerceError.details\`:
1502
+
1503
+ \`\`\`typescript
1504
+ try {
1505
+ await client.smartAddToCart({ productId, quantity: 1, selections });
1506
+ } catch (err) {
1507
+ const e = err as { statusCode?: number; details?: { code?: string; errors?: Array<{ code: string; message: string; modifierGroupId?: string; modifierId?: string }> } };
1508
+ if (e.statusCode === 400 && e.details?.code === 'MODIFIER_VALIDATION_FAILED') {
1509
+ for (const issue of e.details.errors ?? []) {
1510
+ // issue.code is one of: REQUIRED_GROUP_MISSING | MIN_SELECTIONS_NOT_MET |
1511
+ // MAX_SELECTIONS_EXCEEDED | SINGLE_GROUP_MULTIPLE_PICKS | UNKNOWN_MODIFIER |
1512
+ // UNKNOWN_GROUP | MODIFIER_DISABLED_FOR_VARIANT | MODIFIER_NOT_AVAILABLE |
1513
+ // NESTED_DEPTH_EXCEEDED | NESTED_REQUIRES_PRODUCT_REF | INVALID_PRICE_DELTA |
1514
+ // MODIFIER_PRICE_FLOOR_VIOLATED
1515
+ console.error(issue.message);
1516
+ }
1517
+ }
1518
+ }
1519
+ \`\`\`
1520
+
1521
+ 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.
1522
+
1523
+ ### Disable-for-variant convention (PRD \xA77.2.2)
1524
+
1525
+ 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.
1526
+
1527
+ ### Common mistakes
1528
+
1529
+ - Treating \`priceDelta\` as a number \u2192 arithmetic precision bugs. Always strings; use \`parseFloat\` only at display time.
1530
+ - Computing the line total client-side \u2192 diverges from the server's free-allocation. Render the server's \`unitPrice\` / \`modifiers[]\` / \`modifiersTotal\` instead.
1531
+ - Rendering a group with \`max: 0\` \u2192 it's the variant-disable signal; treat as absent.
1532
+ - 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.
1533
+ - Building \`selections\` keyed by \`modifier.name\` \u2192 must be \`modifierGroupId\` + \`modifierIds[]\` (the IDs, not names).
1534
+ - Sending \`modifiers\` instead of \`selections\` on add-to-cart \u2192 \`modifiers\` is the response field; the request key is \`selections\`.
1535
+
1536
+ ### Restaurant features (advanced)
1537
+
1538
+ 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.`;
1539
+ }
1298
1540
  function getInventorySection() {
1299
1541
  return `## Inventory, Stock Display & Reservation Countdown
1300
1542
 
@@ -1860,7 +2102,40 @@ const admin = new BrainerceClient({ apiKey: 'brainerce_...' });
1860
2102
  // Email: getEmailTemplates(), createEmailTemplate()
1861
2103
  // Conflicts: getSyncConflicts(), resolveSyncConflict()
1862
2104
  // OAuth: getOAuthProviders(), configureOAuthProvider()
1863
- \`\`\``;
2105
+ \`\`\`
2106
+
2107
+ ### Per-Channel Publishing
2108
+
2109
+ Categories, tags, brands, and metafield definitions are gated to specific
2110
+ vibe-coded sites \u2014 same control already exposed for products and coupons.
2111
+ **Explicit opt-in:** an entity is visible to a vibe-coded site only if it
2112
+ has been explicitly published to that connection. Entities with no publish
2113
+ rows are invisible to every site (the merchant must publish them via the
2114
+ dashboard or the admin SDK).
2115
+
2116
+ \`\`\`typescript
2117
+ // Publish to a specific vibe-coded site (admin mode):
2118
+ await admin.publishCategoryToVibeCodedSite('cat_id', 'conn_id');
2119
+ await admin.publishTagToVibeCodedSite('tag_id', 'conn_id');
2120
+ await admin.publishBrandToVibeCodedSite('brand_id', 'conn_id');
2121
+ await admin.publishMetafieldDefinitionToVibeCodedSite('def_id', 'conn_id');
2122
+
2123
+ // Unpublish (entity stays visible to other sites unless they also have publishes):
2124
+ await admin.unpublishCategoryFromVibeCodedSite('cat_id', 'conn_id');
2125
+ // ...same for tag/brand/metafield definition.
2126
+
2127
+ // Read which sites an entity is published to via list/get responses:
2128
+ const cat = await admin.getCategory('cat_id');
2129
+ cat.vibeCodedPublishes; // [{ connection: { id, name, connectionId } }, ...]
2130
+ \`\`\`
2131
+
2132
+ Cross-account isolation is enforced server-side: a publish call only
2133
+ succeeds when entity and connection belong to the same account. Cross-account
2134
+ calls fail with \`404 Not Found\`.
2135
+
2136
+ The vibe-coded read endpoints (used by storefront SDK calls in \`connectionId\`
2137
+ mode) automatically filter by these junction tables, so storefronts never see
2138
+ entities that weren't published to their site.`;
1864
2139
  }
1865
2140
  function getContactInquiriesSection() {
1866
2141
  return `## Contact Inquiries & Forms (optional)
@@ -1935,7 +2210,7 @@ const form = await brainerce.contactForms.get('main', 'en');
1935
2210
  // \u2192 {
1936
2211
  // id, key, name, description?, submitButton, successMessage,
1937
2212
  // fields: [{ key, type, label, placeholder?, helpText?, isRequired,
1938
- // enumValues?, validation?, defaultValue? }, ...]
2213
+ // enumValues?, validation?, defaultValue?, width? }, ...]
1939
2214
  // }
1940
2215
 
1941
2216
  // Submit a keyed payload. Unknown keys are stripped, required fields are
@@ -1961,6 +2236,20 @@ stripped server-side.
1961
2236
  Render every field type the merchant can pick. Keep this as a \`<DynamicField>\`
1962
2237
  component so the form is fully driven by \`schema.fields\`.
1963
2238
 
2239
+ **Layout.** Each field carries an optional \`width\` hint:
2240
+
2241
+ | \`field.width\` | Meaning | Grid style (6-column grid) |
2242
+ | ------------- | ------- | -------------------------- |
2243
+ | \`'FULL'\` (default) | Full row | \`grid-column: span 6\` |
2244
+ | \`'HALF'\` | Half row | \`grid-column: span 3\` |
2245
+ | \`'THIRD'\` | One-third row | \`grid-column: span 2\` |
2246
+
2247
+ Stack fields to full width on small screens (< ~640 px).
2248
+
2249
+ **Required fields.** Show a red asterisk on required labels, validate
2250
+ **client-side** before calling \`createInquiry()\`, and show inline error
2251
+ messages. Do NOT rely only on the server returning 400.
2252
+
1964
2253
  \`\`\`tsx
1965
2254
  import type { ContactFormPublic, ContactFormPublicField } from 'brainerce';
1966
2255
 
@@ -1978,13 +2267,24 @@ function isEmpty(v: FieldValue): boolean {
1978
2267
  return v === false;
1979
2268
  }
1980
2269
 
2270
+ // Map width \u2192 CSS grid column span (6-column grid)
2271
+ function widthClass(w?: string): string {
2272
+ switch (w) {
2273
+ case 'HALF': return 'grid-col-half'; // grid-column: span 3; on sm+, full on mobile
2274
+ case 'THIRD': return 'grid-col-third'; // grid-column: span 2; on sm+, full on mobile
2275
+ default: return 'grid-col-full'; // grid-column: span 6
2276
+ }
2277
+ }
2278
+
1981
2279
  function DynamicField({
1982
2280
  field,
1983
2281
  value,
2282
+ error,
1984
2283
  onChange,
1985
2284
  }: {
1986
2285
  field: ContactFormPublicField;
1987
2286
  value: FieldValue;
2287
+ error?: string;
1988
2288
  onChange: (v: FieldValue) => void;
1989
2289
  }) {
1990
2290
  const id = \`contact-\${field.key}\`;
@@ -1999,37 +2299,40 @@ function DynamicField({
1999
2299
  </label>
2000
2300
  );
2001
2301
  const help = field.helpText ? <p className="mt-1 text-xs opacity-70">{field.helpText}</p> : null;
2302
+ const errorEl = error ? <p className="mt-1 text-xs text-red-500">{error}</p> : null;
2002
2303
 
2304
+ // Outer div uses widthClass to control grid-column span
2003
2305
  switch (field.type) {
2004
2306
  case 'TEXTAREA':
2005
- return (<div>{label}<textarea id={id} required={field.isRequired} maxLength={maxLength} minLength={minLength} rows={6} placeholder={field.placeholder} value={strVal} onChange={(e) => onChange(e.target.value)} />{help}</div>);
2307
+ 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>);
2006
2308
  case 'EMAIL':
2007
- return (<div>{label}<input id={id} type="email" required={field.isRequired} autoComplete="email" placeholder={field.placeholder} value={strVal} onChange={(e) => onChange(e.target.value)} />{help}</div>);
2309
+ 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>);
2008
2310
  case 'PHONE':
2009
- return (<div>{label}<input id={id} type="tel" required={field.isRequired} autoComplete="tel" placeholder={field.placeholder} value={strVal} onChange={(e) => onChange(e.target.value)} />{help}</div>);
2311
+ 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>);
2010
2312
  case 'URL':
2011
- return (<div>{label}<input id={id} type="url" required={field.isRequired} placeholder={field.placeholder} value={strVal} onChange={(e) => onChange(e.target.value)} />{help}</div>);
2313
+ 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>);
2012
2314
  case 'NUMBER':
2013
- return (<div>{label}<input id={id} type="number" required={field.isRequired} min={min} max={max} placeholder={field.placeholder} value={strVal} onChange={(e) => onChange(e.target.value)} />{help}</div>);
2315
+ 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>);
2014
2316
  case 'DATE':
2015
- return (<div>{label}<input id={id} type="date" required={field.isRequired} value={strVal} onChange={(e) => onChange(e.target.value)} />{help}</div>);
2317
+ 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>);
2016
2318
  case 'SELECT':
2017
- return (<div>{label}<select id={id} required={field.isRequired} value={strVal} onChange={(e) => onChange(e.target.value)}><option value="">\u2014</option>{field.enumValues?.map((o) => (<option key={o.value} value={o.value}>{o.label}</option>))}</select>{help}</div>);
2319
+ 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>);
2018
2320
  case 'MULTI_SELECT': {
2019
2321
  const arr = Array.isArray(value) ? value : [];
2020
- return (<div>{label}<div>{field.enumValues?.map((o) => { const checked = arr.includes(o.value); return (<label key={o.value}><input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked ? [...arr, o.value] : arr.filter((v) => v !== o.value))} /><span>{o.label}</span></label>); })}</div>{help}</div>);
2322
+ 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>);
2021
2323
  }
2022
2324
  case 'CHECKBOX':
2023
- return (<div><label htmlFor={id}><input id={id} type="checkbox" required={field.isRequired} checked={value === true} onChange={(e) => onChange(e.target.checked)} /><span>{field.label}</span></label>{help}</div>);
2325
+ 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>);
2024
2326
  case 'TEXT':
2025
2327
  default:
2026
- return (<div>{label}<input id={id} type="text" required={field.isRequired} maxLength={maxLength} minLength={minLength} pattern={pattern} placeholder={field.placeholder} value={strVal} onChange={(e) => onChange(e.target.value)} />{help}</div>);
2328
+ 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>);
2027
2329
  }
2028
2330
  }
2029
2331
 
2030
2332
  export function ContactPage() {
2031
2333
  const [schema, setSchema] = useState<ContactFormPublic | null>(null);
2032
2334
  const [values, setValues] = useState<Record<string, FieldValue>>({});
2335
+ const [errors, setErrors] = useState<Record<string, string>>({});
2033
2336
  const [honeypot, setHoneypot] = useState('');
2034
2337
  const [sent, setSent] = useState(false);
2035
2338
  const [loading, setLoading] = useState(false);
@@ -2053,6 +2356,19 @@ export function ContactPage() {
2053
2356
  if (loading) return;
2054
2357
  if (honeypot.trim().length > 0) { setSent(true); return; } // bot \u2192 silent success
2055
2358
 
2359
+ // \u2500\u2500 Client-side required-field validation \u2500\u2500
2360
+ const newErrors: Record<string, string> = {};
2361
+ for (const f of schema.fields) {
2362
+ if (f.isRequired && isEmpty(values[f.key] ?? defaultValueFor(f))) {
2363
+ newErrors[f.key] = \`\${f.label} is required\`; // or pull from your i18n system
2364
+ }
2365
+ }
2366
+ if (Object.keys(newErrors).length > 0) {
2367
+ setErrors(newErrors);
2368
+ return; // block submission
2369
+ }
2370
+ setErrors({});
2371
+
2056
2372
  setLoading(true);
2057
2373
  try {
2058
2374
  const payload: Record<string, unknown> = {};
@@ -2085,11 +2401,15 @@ export function ContactPage() {
2085
2401
  value={honeypot} onChange={(e) => setHoneypot(e.target.value)} />
2086
2402
  </div>
2087
2403
 
2088
- {schema.fields.map((field) => (
2089
- <DynamicField key={field.key} field={field}
2090
- value={values[field.key] ?? defaultValueFor(field)}
2091
- onChange={(v) => setValues((p) => ({ ...p, [field.key]: v }))} />
2092
- ))}
2404
+ {/* CSS grid \u2014 6 columns on sm+, 1 column on mobile */}
2405
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(6, 1fr)', gap: '1rem' }}>
2406
+ {schema.fields.map((field) => (
2407
+ <DynamicField key={field.key} field={field}
2408
+ value={values[field.key] ?? defaultValueFor(field)}
2409
+ error={errors[field.key]}
2410
+ onChange={(v) => setValues((p) => ({ ...p, [field.key]: v }))} />
2411
+ ))}
2412
+ </div>
2093
2413
 
2094
2414
  <button type="submit" disabled={loading}>
2095
2415
  {loading ? '\u2026' : schema.submitButton}
@@ -2099,13 +2419,27 @@ export function ContactPage() {
2099
2419
  }
2100
2420
  \`\`\`
2101
2421
 
2422
+ CSS for the grid column helpers (or use the equivalent Tailwind / inline styles):
2423
+
2424
+ \`\`\`css
2425
+ .grid-col-full { grid-column: span 6; }
2426
+ .grid-col-half { grid-column: span 6; }
2427
+ .grid-col-third { grid-column: span 6; }
2428
+
2429
+ @media (min-width: 640px) {
2430
+ .grid-col-half { grid-column: span 3; }
2431
+ .grid-col-third { grid-column: span 2; }
2432
+ }
2433
+ \`\`\`
2434
+
2102
2435
  ### Rules
2103
2436
 
2104
2437
  - **Rate limit:** 3 submissions / 60s per IP \u2014 show a friendly "try again later" message on HTTP 429.
2105
2438
  - **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\`.
2106
2439
  - **Built-in keys:** \`name\`, \`email\`, \`phone\`, \`subject\`, \`message\` always exist on the default form; the legacy \`createInquiry\` shape keeps working forever.
2107
2440
  - **Max values:** each field value is capped at 10 000 chars server-side. Validate client-side before submitting using \`field.validation.maxLength\`.
2108
- - **Required fields:** respect \`field.isRequired\`. The server validates too, but show \`required\` on the input for browser-level UX.
2441
+ - **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.
2442
+ - **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\`.
2109
2443
  - **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\`.
2110
2444
  - **Enum values:** SELECT and MULTI_SELECT always return \`enumValues\` (non-empty array). Render from \`enumValues\`, never a hardcoded list.
2111
2445
  - **Visibility:** the server strips fields with \`isVisible=false\`, so \`schema.fields\` only contains things to render.
@@ -2137,6 +2471,8 @@ function getSectionByTopic(topic, connectionId, currency) {
2137
2471
  return getCheckoutCustomFieldsSection();
2138
2472
  case "payment":
2139
2473
  return getPaymentProvidersSection();
2474
+ case "saved-payment-methods":
2475
+ return getSavedPaymentMethodsSection();
2140
2476
  case "auth":
2141
2477
  return getCustomerAuthSection();
2142
2478
  case "order-confirmation":
@@ -2149,6 +2485,8 @@ function getSectionByTopic(topic, connectionId, currency) {
2149
2485
  return getRecommendationsSection();
2150
2486
  case "product-customization-fields":
2151
2487
  return getProductCustomizationFieldsSection();
2488
+ case "modifier-groups":
2489
+ return getModifierGroupsSection();
2152
2490
  case "tax":
2153
2491
  return getTaxDisplaySection(cur);
2154
2492
  case "i18n":
@@ -2229,6 +2567,10 @@ function getSectionByTopic(topic, connectionId, currency) {
2229
2567
  "",
2230
2568
  "---",
2231
2569
  "",
2570
+ getModifierGroupsSection(),
2571
+ "",
2572
+ "---",
2573
+ "",
2232
2574
  getTaxDisplaySection(cur),
2233
2575
  "",
2234
2576
  "---",
@@ -2273,11 +2615,19 @@ var GET_SDK_DOCS_SCHEMA = {
2273
2615
  "inquiries",
2274
2616
  "all"
2275
2617
  ]).describe("The SDK documentation topic to retrieve"),
2276
- connectionId: import_zod.z.string().optional().describe("Vibe-coded connection ID (starts with vc_). Used to personalize setup code."),
2618
+ salesChannelId: import_zod.z.string().optional().describe("Sales channel ID (starts with vc_). Used to personalize setup code."),
2619
+ /** @deprecated alias of salesChannelId */
2620
+ connectionId: import_zod.z.string().optional().describe("Deprecated alias of salesChannelId \u2014 kept for backwards compat"),
2277
2621
  currency: import_zod.z.string().optional().describe("Store currency code (e.g., USD, ILS, EUR). Used in price formatting examples.")
2278
2622
  };
2279
2623
  async function handleGetSdkDocs(args) {
2280
- const content = getSectionByTopic(args.topic, args.connectionId, args.currency);
2624
+ const id = args.salesChannelId ?? args.connectionId;
2625
+ if (!args.salesChannelId && args.connectionId) {
2626
+ console.warn(
2627
+ "get-sdk-docs: `connectionId` is deprecated \u2014 use `salesChannelId` instead"
2628
+ );
2629
+ }
2630
+ const content = getSectionByTopic(args.topic, id, args.currency);
2281
2631
  return {
2282
2632
  content: [{ type: "text", text: content }]
2283
2633
  };
@@ -2316,7 +2666,7 @@ interface Product {
2316
2666
  attributeId: string;
2317
2667
  attributeOptionId: string;
2318
2668
  platform: string;
2319
- attribute: { id: string; name: string; displayType?: 'DEFAULT' | 'COLOR_SWATCH' | 'IMAGE_SWATCH' } | null;
2669
+ attribute: { id: string; name: string; displayType?: 'DEFAULT' | 'COLOR_SWATCH' | 'IMAGE_SWATCH' | 'MIXED_SWATCH' } | null;
2320
2670
  attributeOption: { id: string; name: string; value?: string | null; swatchColor?: string | null; swatchColor2?: string | null; swatchImageUrl?: string | null } | null;
2321
2671
  }>;
2322
2672
  createdAt: string;
@@ -2325,6 +2675,8 @@ interface Product {
2325
2675
 
2326
2676
  // Use getProductSwatches(product) to get grouped swatch data for storefront rendering.
2327
2677
  // Returns Array<{ attributeName, displayType, options: Array<{ name, swatchColor?, swatchColor2?, swatchImageUrl? }> }>
2678
+ // displayType rendering: COLOR_SWATCH \u2192 color circle (swatchColor/swatchColor2); IMAGE_SWATCH \u2192 image thumbnail (swatchImageUrl);
2679
+ // MIXED_SWATCH \u2192 per-option: render swatchImageUrl if set, else swatchColor if set, else text only.
2328
2680
 
2329
2681
  interface ProductImage {
2330
2682
  url: string;
@@ -2405,6 +2757,10 @@ interface ProductQueryParams {
2405
2757
  tags?: string | string[];
2406
2758
  minPrice?: number;
2407
2759
  maxPrice?: number;
2760
+ // Filter by custom-field (metafield) values. Keys = definition.key,
2761
+ // values = accepted values. Only definitions with filterable=true and
2762
+ // type SELECT/MULTI_SELECT/BOOLEAN are honored. AND across keys, OR within.
2763
+ metafields?: Record<string, string | string[]>;
2408
2764
  sortBy?: 'name' | 'price' | 'createdAt';
2409
2765
  sortOrder?: 'asc' | 'desc';
2410
2766
  }
@@ -2822,6 +3178,36 @@ interface PaymentStatus {
2822
3178
  error?: string;
2823
3179
  }
2824
3180
 
3181
+ // ---- Saved Payment Methods (vaulted cards) ----
3182
+ // Display-only summary of a customer's vaulted payment method. The
3183
+ // underlying provider token is encrypted at rest in the platform DB
3184
+ // and NEVER returned through the SDK.
3185
+ //
3186
+ // To opt into vaulting at checkout, pass saveCard: true to
3187
+ // createPaymentIntent (only honored for logged-in customers).
3188
+ //
3189
+ // To list / remove saved methods, see:
3190
+ // client.listSavedPaymentMethods(storeId, customerId)
3191
+ // client.removeSavedPaymentMethod(storeId, customerId, methodId)
3192
+
3193
+ interface SavedPaymentMethodSummary {
3194
+ id: string;
3195
+ customerId: string;
3196
+ appInstallationId: string;
3197
+ paymentMethod: string; // 'credit_card' | 'paypal' | 'bank_account'
3198
+ brand: string | null;
3199
+ last4: string | null;
3200
+ expMonth: number | null;
3201
+ expYear: number | null;
3202
+ isDefault: boolean;
3203
+ status: string; // 'active' | 'expired' | 'invalid'
3204
+ failureReason: string | null;
3205
+ lastUsedAt: string | null;
3206
+ expiresAt: string | null;
3207
+ createdAt: string;
3208
+ updatedAt: string;
3209
+ }
3210
+
2825
3211
  interface WaitForOrderResult {
2826
3212
  success: boolean;
2827
3213
  status: PaymentStatus; // Access: result.status.orderNumber
@@ -2921,15 +3307,32 @@ interface CartUpgradeSuggestion {
2921
3307
  priceDelta: string;
2922
3308
  deltaPercent: number;
2923
3309
  }
2924
- interface CartBundleOffer {
3310
+ interface CartBundleOfferOfferedProduct {
2925
3311
  id: string;
2926
- bundleProduct: ProductRecommendation;
3312
+ name: string;
3313
+ slug: string | null;
3314
+ basePrice: string;
3315
+ salePrice: string | null;
3316
+ images: Array<{ url: string }>;
3317
+ type: string;
2927
3318
  originalPrice: string;
2928
3319
  discountedPrice: string;
3320
+ }
3321
+ interface CartBundleOffer {
3322
+ id: string;
3323
+ name: string;
3324
+ description: string | null;
3325
+ // productIds[0] = trigger product (must be in cart for the bundle to surface);
3326
+ // productIds[1..] = offered together at the bundle discount.
3327
+ triggerProductId: string;
3328
+ productIds: string[];
3329
+ // offeredProducts = productIds[1..] minus those already in cart, each with
3330
+ // its own original/discounted price applied.
3331
+ offeredProducts: CartBundleOfferOfferedProduct[];
2929
3332
  discountType: 'PERCENTAGE' | 'FIXED_AMOUNT';
2930
- discountValue: number;
2931
- requiresVariantSelection: boolean;
2932
- lockedVariant?: { id: string; name?: string };
3333
+ discountValue: string;
3334
+ totalOriginalPrice: string;
3335
+ totalDiscountedPrice: string;
2933
3336
  }`;
2934
3337
  var INQUIRIES_TYPES = `// ---- Contact Inquiries & Forms ----
2935
3338
 
@@ -2984,6 +3387,7 @@ interface ContactFormPublicField {
2984
3387
  enumValues?: { value: string; label: string }[]; // present (non-empty) for SELECT / MULTI_SELECT
2985
3388
  validation?: ContactFormFieldValidation;
2986
3389
  defaultValue?: string;
3390
+ width?: 'FULL' | 'HALF' | 'THIRD'; // layout hint \u2014 FULL = full row, HALF = half row, THIRD = one-third row
2987
3391
  }
2988
3392
 
2989
3393
  interface ContactFormPublic {
@@ -3015,6 +3419,19 @@ interface ContactFormSummary {
3015
3419
  // MULTI_SELECT \u2192 multiple <input type="checkbox">, value is string[]
3016
3420
  // CHECKBOX \u2192 single <input type="checkbox">, value is boolean
3017
3421
  // ------------------------------------------------------------------
3422
+ //
3423
+ // Layout: render fields inside a CSS grid container.
3424
+ // width='FULL' (default) \u2192 span entire row
3425
+ // width='HALF' \u2192 span half the row (two HALF fields sit side-by-side)
3426
+ // width='THIRD' \u2192 span one-third of the row (three THIRD fields sit side-by-side)
3427
+ // Use a 6-column grid for clean divisibility:
3428
+ // FULL \u2192 col-span-6 | HALF \u2192 col-span-3 | THIRD \u2192 col-span-2
3429
+ // Stack to full width on small screens (< sm breakpoint).
3430
+ //
3431
+ // Required fields: show a red asterisk (*) next to the label, validate
3432
+ // client-side before submission, and display inline error messages for
3433
+ // any empty required field. Do NOT rely only on the server returning 400.
3434
+ // ------------------------------------------------------------------
3018
3435
 
3019
3436
  // SDK methods
3020
3437
  // await brainerce.createInquiry(input) \u2192 POST /stores/{storeId}/inquiries
@@ -3027,6 +3444,130 @@ interface ContactFormSummary {
3027
3444
  // - Always pass \`locale\` \u2014 inbox filters inquiries by language, and schema labels come back translated
3028
3445
  // - Render \`schema.name\` / \`description\` / \`submitButton\` / \`successMessage\` directly \u2014 do NOT hardcode copy
3029
3446
  // - Unknown field keys are stripped server-side \u2014 safe to send extras during dev`;
3447
+ var MODIFIER_GROUPS_TYPES = `// ---- Modifier Groups (Restaurant / Build-Your-Own) ----
3448
+ // Modifier groups are merchant-defined option blocks attached to a product
3449
+ // (toppings, sauce, bread type, \u2026). They differ from product.customizationFields:
3450
+ // modifier groups are STRUCTURED priced choices validated server-side, while
3451
+ // customizationFields are arbitrary buyer input (text/photo/color).
3452
+ //
3453
+ // Money fields are decimal STRINGS on the wire \u2014 "5.00", "-2.00" (downsell).
3454
+ // Never JSON Number. Use parseFloat() only at display time. Do not compute
3455
+ // the line total client-side; the server runs free-allocation and returns
3456
+ // cart.items[i].unitPrice + .modifiers[] + .modifiersTotal.
3457
+
3458
+ export type ModifierSelectionType = 'SINGLE' | 'MULTIPLE';
3459
+
3460
+ export type FreeAllocationPolicy = 'EXPENSIVE_FREE' | 'CHEAPEST_FREE' | 'SELECTION_ORDER';
3461
+
3462
+ export interface Modifier {
3463
+ id: string;
3464
+ name: string;
3465
+ description?: string;
3466
+ /** Decimal string. Negative values = downsell modifiers ("-2.00"). */
3467
+ priceDelta: string;
3468
+ sku?: string;
3469
+ image?: { url: string; thumbnailUrl?: string; alt?: string };
3470
+ position: number;
3471
+ /** Pre-checked on first render. */
3472
+ isDefault: boolean;
3473
+ /** false = sold out \u2014 disable in UI with a "Sold out" badge. */
3474
+ available: boolean;
3475
+ /** When true, never applied as a free selection \u2014 always charges priceDelta even when freeQuantity > 0 on the group. */
3476
+ excludeFromFree?: boolean;
3477
+ /** Nested combo: opens a sub-flow; depth \u2264 3 enforced server-side. */
3478
+ referencedProductId?: string;
3479
+ translations?: Record<string, { name?: string; description?: string }>;
3480
+ }
3481
+
3482
+ export interface ModifierGroup {
3483
+ id: string;
3484
+ /**
3485
+ * Set when fetched in a product context (the ProductModifierGroup row id).
3486
+ * Use this to update or detach the attachment without reattaching the group.
3487
+ */
3488
+ attachmentId?: string;
3489
+ /** Customer-facing canonical name. */
3490
+ name: string;
3491
+ /**
3492
+ * Admin-only disambiguator \u2014 NEVER present in storefront responses.
3493
+ * If your client code reads this, you're hitting an admin endpoint by mistake.
3494
+ */
3495
+ internalName?: string;
3496
+ description?: string;
3497
+ selectionType: ModifierSelectionType;
3498
+ /** Effective minimum after any per-attach / per-variant overrides. */
3499
+ min: number;
3500
+ /** Effective maximum; null = unlimited. **0 = group hidden for this variant** (PRD \xA77.2.2). */
3501
+ max?: number | null;
3502
+ freeQuantity: number;
3503
+ required: boolean;
3504
+ freeAllocationPolicy: FreeAllocationPolicy;
3505
+ modifiers: Modifier[];
3506
+ /** Effective default selections (after attachment-level overrides). */
3507
+ defaultModifierIds: string[];
3508
+ translations?: Record<string, { name?: string; description?: string }>;
3509
+ }
3510
+
3511
+ /** Customer-side selection payload \u2014 modifierIds in click-order. */
3512
+ export interface ModifierSelection {
3513
+ modifierGroupId: string;
3514
+ modifierIds: string[];
3515
+ }
3516
+
3517
+ /** Per-line modifier breakdown surfaced on cart.items[i] / order.items[i]. */
3518
+ export interface CartItemModifierLine {
3519
+ modifierId: string;
3520
+ /** Snapshot of the modifier's name at the time the line was added. */
3521
+ name: string;
3522
+ /** Decimal string snapshot of the priceDelta at the time the line was added. */
3523
+ priceDelta: string;
3524
+ /** True if this modifier consumed one of the group's free slots. */
3525
+ freeApplied: boolean;
3526
+ }
3527
+
3528
+ /**
3529
+ * Stable error codes returned in the structured 400 envelope when a cart
3530
+ * payload fails server-side validation. The SDK exposes the envelope on
3531
+ * BrainerceError.details \u2014 switch on details.code === 'MODIFIER_VALIDATION_FAILED'
3532
+ * first, then iterate details.errors[].
3533
+ */
3534
+ export type ModifierValidationCode =
3535
+ | 'REQUIRED_GROUP_MISSING'
3536
+ | 'MIN_SELECTIONS_NOT_MET'
3537
+ | 'MAX_SELECTIONS_EXCEEDED'
3538
+ | 'SINGLE_GROUP_MULTIPLE_PICKS'
3539
+ | 'UNKNOWN_MODIFIER'
3540
+ | 'UNKNOWN_GROUP'
3541
+ | 'MODIFIER_DISABLED_FOR_VARIANT'
3542
+ | 'MODIFIER_NOT_AVAILABLE'
3543
+ | 'NESTED_DEPTH_EXCEEDED'
3544
+ | 'NESTED_REQUIRES_PRODUCT_REF'
3545
+ | 'INVALID_PRICE_DELTA'
3546
+ | 'MODIFIER_PRICE_FLOOR_VIOLATED';
3547
+
3548
+ export interface ModifierValidationError {
3549
+ code: ModifierValidationCode;
3550
+ message: string;
3551
+ modifierGroupId?: string;
3552
+ modifierId?: string;
3553
+ }
3554
+
3555
+ // Cart DTOs gain optional selections + nestedByModifierId \u2014 see CART_TYPES above.
3556
+ // Add to cart with selections:
3557
+ //
3558
+ // await client.smartAddToCart({
3559
+ // productId,
3560
+ // variantId,
3561
+ // quantity: 1,
3562
+ // selections: [
3563
+ // { modifierGroupId: 'mg_bread', modifierIds: ['m_thick'] },
3564
+ // { modifierGroupId: 'mg_toppings', modifierIds: ['m_olive', 'm_bacon'] },
3565
+ // ],
3566
+ // });
3567
+ //
3568
+ // The cart line then carries:
3569
+ // cart.items[i].modifiers \u2014 CartItemModifierLine[]
3570
+ // cart.items[i].modifiersTotal \u2014 decimal string of the paid (non-free) deltas`;
3030
3571
  var TYPES_BY_DOMAIN = {
3031
3572
  products: PRODUCTS_TYPES,
3032
3573
  cart: CART_TYPES,
@@ -3035,7 +3576,8 @@ var TYPES_BY_DOMAIN = {
3035
3576
  customers: CUSTOMERS_TYPES,
3036
3577
  payments: PAYMENTS_TYPES,
3037
3578
  helpers: HELPERS_TYPES,
3038
- inquiries: INQUIRIES_TYPES
3579
+ inquiries: INQUIRIES_TYPES,
3580
+ "modifier-groups": MODIFIER_GROUPS_TYPES
3039
3581
  };
3040
3582
  function getTypesByDomain(domain) {
3041
3583
  if (domain === "all") {
@@ -3101,7 +3643,12 @@ var GET_CODE_EXAMPLE_SCHEMA = {
3101
3643
  "reservation-countdown",
3102
3644
  "search-autocomplete-debounce",
3103
3645
  "i18n-set-locale",
3104
- "order-history-full"
3646
+ "order-history-full",
3647
+ "modifier-groups-render",
3648
+ "cart-add-with-selections",
3649
+ "modifier-validation-error-handling",
3650
+ "checkout-price-drift",
3651
+ "cart-item-modifier-display"
3105
3652
  ]).describe("The SDK operation to get a snippet for.")
3106
3653
  };
3107
3654
  var SNIPPETS = {
@@ -3109,7 +3656,10 @@ var SNIPPETS = {
3109
3656
  import { BrainerceClient } from 'brainerce';
3110
3657
 
3111
3658
  export const client = new BrainerceClient({
3112
- connectionId: process.env.BRAINERCE_CONNECTION_ID!, // vc_*
3659
+ // Read either env var name (the new one is preferred; the old one is a soft
3660
+ // alias kept for backwards compatibility \u2014 both are accepted by the SDK).
3661
+ salesChannelId:
3662
+ process.env.BRAINERCE_SALES_CHANNEL_ID! ?? process.env.BRAINERCE_CONNECTION_ID!, // vc_*
3113
3663
  });
3114
3664
 
3115
3665
  // If the store has i18n enabled, set the locale at app start based on
@@ -3529,7 +4079,418 @@ for (const order of orders) {
3529
4079
 
3530
4080
  // All sections above are conditional. Absent data = section not rendered \u2014
3531
4081
  // never show empty placeholders. The create-brainerce-store template's
3532
- // src/components/account/order-history.tsx is the reference implementation.`
4082
+ // src/components/account/order-history.tsx is the reference implementation.`,
4083
+ "modifier-groups-render": `// Render modifier groups on the product detail page (toppings / sauce /
4084
+ // build-your-own). The data lives on product.modifierGroups \u2014 only present
4085
+ // for restaurant / customizable products.
4086
+ import { useState } from 'react';
4087
+ import type { ModifierGroup, Product } from 'brainerce';
4088
+
4089
+ function ModifierGroups({ product }: { product: Product }) {
4090
+ const groups: ModifierGroup[] = product.modifierGroups ?? [];
4091
+ if (groups.length === 0) return null; // not a customizable product
4092
+
4093
+ // Local state: selected modifier IDs per group, in click-order.
4094
+ const [selections, setSelections] = useState<Record<string, string[]>>(() =>
4095
+ buildInitialSelections(groups)
4096
+ );
4097
+
4098
+ return (
4099
+ <>
4100
+ {groups.map((group) => {
4101
+ // PRD \xA77.2.2: max === 0 means "hidden for this variant" \u2014 skip entirely.
4102
+ if (group.max === 0) return null;
4103
+
4104
+ const isSingle = group.selectionType === 'SINGLE';
4105
+ const inputType = isSingle ? 'radio' : 'checkbox';
4106
+ const sorted = [...group.modifiers].sort((a, b) => a.position - b.position);
4107
+ const picks = selections[group.id] ?? [];
4108
+ const usedFree = Math.min(picks.length, group.freeQuantity);
4109
+
4110
+ const toggle = (modifierId: string, checked: boolean) => {
4111
+ setSelections((prev) => {
4112
+ const cur = prev[group.id] ?? [];
4113
+ if (isSingle) return { ...prev, [group.id]: checked ? [modifierId] : [] };
4114
+ if (checked) {
4115
+ if (group.max != null && cur.length >= group.max) return prev; // at cap
4116
+ return { ...prev, [group.id]: [...cur, modifierId] };
4117
+ }
4118
+ return { ...prev, [group.id]: cur.filter((id) => id !== modifierId) };
4119
+ });
4120
+ };
4121
+
4122
+ return (
4123
+ <fieldset key={group.id}>
4124
+ <legend>
4125
+ {group.name}{group.required && ' *'}
4126
+ </legend>
4127
+ {group.freeQuantity > 0 && (
4128
+ <p>{usedFree} of {group.freeQuantity} free</p>
4129
+ )}
4130
+ {sorted.map((m) => (
4131
+ <label key={m.id}>
4132
+ <input
4133
+ type={inputType}
4134
+ name={\`mg-\${group.id}\`}
4135
+ value={m.id}
4136
+ checked={picks.includes(m.id)}
4137
+ disabled={!m.available}
4138
+ onChange={(e) => toggle(m.id, e.target.checked)}
4139
+ />
4140
+ {m.name}
4141
+ {parseFloat(m.priceDelta) !== 0 && (
4142
+ <span> {parseFloat(m.priceDelta) > 0 ? '+' : ''}{m.priceDelta}</span>
4143
+ )}
4144
+ {!m.available && <span> (Sold out)</span>}
4145
+ </label>
4146
+ ))}
4147
+ </fieldset>
4148
+ );
4149
+ })}
4150
+ </>
4151
+ );
4152
+ }
4153
+
4154
+ // Initial state: prefer per-attach defaultModifierIds; fall back to
4155
+ // modifier.isDefault flags. Filter sold-out modifiers.
4156
+ function buildInitialSelections(groups: ModifierGroup[]): Record<string, string[]> {
4157
+ const out: Record<string, string[]> = {};
4158
+ for (const group of groups) {
4159
+ if (group.max === 0) continue;
4160
+ const fromAttach = group.defaultModifierIds ?? [];
4161
+ const fromIsDefault = group.modifiers
4162
+ .filter((m) => m.isDefault && m.available)
4163
+ .map((m) => m.id);
4164
+ const merged =
4165
+ fromAttach.length > 0
4166
+ ? fromAttach.filter((id) => group.modifiers.some((m) => m.id === id && m.available))
4167
+ : fromIsDefault;
4168
+ const capped = group.selectionType === 'SINGLE' ? merged.slice(0, 1) : merged;
4169
+ if (capped.length > 0) out[group.id] = capped;
4170
+ }
4171
+ return out;
4172
+ }
4173
+
4174
+ // PRICE-DELTA RULES:
4175
+ // - "5.00" \u2192 +$5 added to unit price
4176
+ // - "-2.00" \u2192 downsell: subtracts $2; never consumes a free slot
4177
+ // - "0.00" \u2192 free option; render no price label
4178
+ // Server enforces unitPrice >= 0; rejects negative deltas combined with
4179
+ // referencedProductId. Do NOT compute the line total client-side \u2014 the
4180
+ // server runs free-allocation and returns cart.items[i].unitPrice.
4181
+
4182
+ `,
4183
+ "cart-add-with-selections": `// Pass modifier-group selections on add-to-cart. The server validates
4184
+ // against the effective rules and computes the final unitPrice (base +
4185
+ // paid modifiers, after the free-allocation policy runs).
4186
+ import { client } from './brainerce';
4187
+ import type { ModifierSelection } from 'brainerce';
4188
+
4189
+ async function addPizzaToCart(
4190
+ productId: string,
4191
+ variantId: string,
4192
+ selectionsByGroup: Record<string, string[]>
4193
+ ) {
4194
+ // Adapter: shape used in component state \u2192 wire format the SDK accepts.
4195
+ const selections: ModifierSelection[] = Object.entries(selectionsByGroup)
4196
+ .filter(([, modifierIds]) => modifierIds.length > 0)
4197
+ .map(([modifierGroupId, modifierIds]) => ({ modifierGroupId, modifierIds }));
4198
+
4199
+ // modifierIds is in CLICK-ORDER \u2014 used by the SELECTION_ORDER free-allocation
4200
+ // policy. Don't sort it; preserve the order the customer clicked.
4201
+
4202
+ const cart = await client.smartAddToCart({
4203
+ productId,
4204
+ variantId,
4205
+ quantity: 1,
4206
+ selections,
4207
+ });
4208
+
4209
+ // Read the snapshot back off the new cart line.
4210
+ const line = cart.items[cart.items.length - 1];
4211
+ // line.modifiers \u2192 CartItemModifierLine[] with { modifierId, name, priceDelta, freeApplied }
4212
+ // line.modifiersTotal \u2192 decimal string of paid (non-free) deltas
4213
+ // line.unitPrice \u2192 final unit price including all paid modifiers
4214
+ console.log('Free applied:', line.modifiers?.filter((m) => m.freeApplied).map((m) => m.name));
4215
+ }
4216
+
4217
+ // EDITING SELECTIONS LATER (PRD \xA77.2.3 \u2014 idempotent replacement):
4218
+ // PATCH the cart item with a fresh selections array. The server deletes ALL
4219
+ // existing CartItemModifier rows and recreates them in the same transaction.
4220
+ // To leave selections untouched (e.g., quantity-only update), omit them.
4221
+ async function changeToppings(cartId: string, itemId: string, newToppingIds: string[]) {
4222
+ await client.updateCartItem(cartId, itemId, {
4223
+ quantity: 1,
4224
+ selections: [{ modifierGroupId: 'mg_toppings', modifierIds: newToppingIds }],
4225
+ });
4226
+ }
4227
+
4228
+ // NESTED COMBOS (depth \u2264 3): when a picked modifier carries
4229
+ // referencedProductId, fetch that product's own modifierGroups, collect the
4230
+ // nested selections, and pass them keyed by the PARENT modifier id.
4231
+ async function addCombo(productId: string, mainBurger: string) {
4232
+ await client.smartAddToCart({
4233
+ productId,
4234
+ quantity: 1,
4235
+ selections: [
4236
+ { modifierGroupId: 'mg_main', modifierIds: [mainBurger] }, // the burger
4237
+ { modifierGroupId: 'mg_drink', modifierIds: ['m_cola'] },
4238
+ ],
4239
+ nestedByModifierId: {
4240
+ [mainBurger]: [
4241
+ { modifierGroupId: 'mg_doneness', modifierIds: ['m_medium'] },
4242
+ { modifierGroupId: 'mg_cheese', modifierIds: ['m_cheddar'] },
4243
+ ],
4244
+ },
4245
+ });
4246
+ }`,
4247
+ "modifier-validation-error-handling": `// Surface MODIFIER_VALIDATION_FAILED to the user. The SDK throws
4248
+ // BrainerceError on HTTP 400; the structured envelope is on .details.
4249
+ import { client } from './brainerce';
4250
+ import type { ModifierSelection } from 'brainerce';
4251
+
4252
+ interface ModifierValidationError {
4253
+ code:
4254
+ | 'REQUIRED_GROUP_MISSING'
4255
+ | 'MIN_SELECTIONS_NOT_MET'
4256
+ | 'MAX_SELECTIONS_EXCEEDED'
4257
+ | 'SINGLE_GROUP_MULTIPLE_PICKS'
4258
+ | 'UNKNOWN_MODIFIER'
4259
+ | 'UNKNOWN_GROUP'
4260
+ | 'MODIFIER_DISABLED_FOR_VARIANT'
4261
+ | 'MODIFIER_NOT_AVAILABLE'
4262
+ | 'NESTED_DEPTH_EXCEEDED'
4263
+ | 'NESTED_REQUIRES_PRODUCT_REF'
4264
+ | 'INVALID_PRICE_DELTA'
4265
+ | 'MODIFIER_PRICE_FLOOR_VIOLATED';
4266
+ message: string;
4267
+ modifierGroupId?: string;
4268
+ modifierId?: string;
4269
+ }
4270
+
4271
+ async function tryAddToCart(productId: string, selections: ModifierSelection[]) {
4272
+ try {
4273
+ await client.smartAddToCart({ productId, quantity: 1, selections });
4274
+ return { ok: true as const };
4275
+ } catch (err) {
4276
+ const e = err as {
4277
+ statusCode?: number;
4278
+ details?: { code?: string; errors?: ModifierValidationError[] };
4279
+ };
4280
+
4281
+ if (e.statusCode === 400 && e.details?.code === 'MODIFIER_VALIDATION_FAILED') {
4282
+ // Surface each issue inline. groupId / modifierId let you highlight the
4283
+ // specific control the customer needs to fix.
4284
+ const issues = e.details.errors ?? [];
4285
+ const messages = issues.map((i) => formatIssue(i));
4286
+ return { ok: false as const, issues, messages };
4287
+ }
4288
+
4289
+ // Some other error (network, 500, etc.) \u2014 re-throw to a generic handler.
4290
+ throw err;
4291
+ }
4292
+ }
4293
+
4294
+ function formatIssue(issue: ModifierValidationError): string {
4295
+ switch (issue.code) {
4296
+ case 'REQUIRED_GROUP_MISSING':
4297
+ return 'Please pick at least one option from this group.';
4298
+ case 'MIN_SELECTIONS_NOT_MET':
4299
+ case 'MAX_SELECTIONS_EXCEEDED':
4300
+ case 'SINGLE_GROUP_MULTIPLE_PICKS':
4301
+ return issue.message; // server's message already says "Pick at least N" / "Pick at most N"
4302
+ case 'UNKNOWN_MODIFIER':
4303
+ case 'UNKNOWN_GROUP':
4304
+ case 'MODIFIER_DISABLED_FOR_VARIANT':
4305
+ case 'MODIFIER_NOT_AVAILABLE':
4306
+ // The catalog moved under us \u2014 refetch the product and ask the customer
4307
+ // to re-pick. These codes mean the option simply isn't valid anymore.
4308
+ return 'This option is no longer available \u2014 please refresh.';
4309
+ case 'NESTED_DEPTH_EXCEEDED':
4310
+ case 'NESTED_REQUIRES_PRODUCT_REF':
4311
+ return issue.message; // bug in your renderer \u2014 never go past 3 levels
4312
+ case 'INVALID_PRICE_DELTA':
4313
+ return issue.message; // shouldn't happen for SDK callers \u2014 server-validated on save
4314
+ case 'MODIFIER_PRICE_FLOOR_VIOLATED':
4315
+ // The server reports a generic message \u2014 internals never leak. Show a
4316
+ // friendly error and let the customer remove a downsell.
4317
+ return 'Cannot apply more discounts on this item.';
4318
+ }
4319
+ }
4320
+
4321
+ // CLIENT-SIDE PRE-FLIGHT (UX):
4322
+ // You can mirror these checks before calling addToCart to give faster
4323
+ // feedback, but the SERVER is authoritative. Always treat the 400 envelope
4324
+ // as the source of truth.
4325
+ function preflightSelections(
4326
+ groups: { id: string; name: string; min: number; max?: number | null; required: boolean; selectionType: 'SINGLE' | 'MULTIPLE'; }[],
4327
+ selections: Record<string, string[]>
4328
+ ): string | null {
4329
+ for (const g of groups) {
4330
+ if (g.max === 0) continue; // disabled-for-variant
4331
+ const picks = selections[g.id] ?? [];
4332
+ if (g.required && picks.length === 0) return \`\${g.name} is required\`;
4333
+ if (picks.length < g.min) return \`Pick at least \${g.min} from \${g.name}\`;
4334
+ if (g.max != null && picks.length > g.max) return \`Pick at most \${g.max} from \${g.name}\`;
4335
+ if (g.selectionType === 'SINGLE' && picks.length > 1) return \`Only one option allowed in \${g.name}\`;
4336
+ }
4337
+ return null;
4338
+ }`,
4339
+ "checkout-price-drift": `// PRICE_DRIFT \u2014 a product's price changed between add-to-cart and checkout.
4340
+ // The server returns HTTP 400 with code "PRICE_DRIFT" when it detects that
4341
+ // the stored unitPrice no longer matches the live price.
4342
+ //
4343
+ // Strategy: catch the error \u2192 show "prices updated" dialog \u2192
4344
+ // call refreshCartSnapshots() to re-snapshot all live prices \u2192
4345
+ // retry createCheckout once.
4346
+
4347
+ import { client } from './brainerce-client';
4348
+
4349
+ interface CheckoutOptions {
4350
+ cartId: string;
4351
+ shippingAddress: object;
4352
+ shippingMethodId: string;
4353
+ paymentProviderId: string;
4354
+ }
4355
+
4356
+ async function createCheckoutSafe(opts: CheckoutOptions) {
4357
+ try {
4358
+ return await client.createCheckout({
4359
+ cartId: opts.cartId,
4360
+ shippingAddress: opts.shippingAddress,
4361
+ shippingMethodId: opts.shippingMethodId,
4362
+ paymentProviderId: opts.paymentProviderId,
4363
+ });
4364
+ } catch (err: unknown) {
4365
+ if (!isApiError(err, 'PRICE_DRIFT')) throw err;
4366
+
4367
+ // Prices changed \u2014 refresh snapshots then retry once.
4368
+ // refreshCartSnapshots updates every cart item's unitPrice to the current
4369
+ // live price (base price + any modifier deltas) so the next checkout call
4370
+ // will pass the drift guard.
4371
+ await client.refreshCartSnapshots(opts.cartId);
4372
+
4373
+ // After refreshing, re-fetch the cart so your UI shows the updated prices
4374
+ // before you retry, giving the customer a chance to review.
4375
+ const updatedCart = await client.getCart(opts.cartId);
4376
+
4377
+ // Signal to the UI layer that prices changed so it can show a dialog.
4378
+ throw Object.assign(new Error('PRICES_UPDATED'), {
4379
+ code: 'PRICES_UPDATED',
4380
+ updatedCart,
4381
+ });
4382
+ }
4383
+ }
4384
+
4385
+ function isApiError(err: unknown, code: string): boolean {
4386
+ return (
4387
+ typeof err === 'object' &&
4388
+ err !== null &&
4389
+ 'code' in err &&
4390
+ (err as { code: string }).code === code
4391
+ );
4392
+ }
4393
+
4394
+ // --- UI integration example (framework-neutral) ---
4395
+ //
4396
+ // async function handlePlaceOrder() {
4397
+ // try {
4398
+ // const checkout = await createCheckoutSafe({ cartId, ... });
4399
+ // router.push(\`/order-confirmation?checkoutId=\${checkout.id}\`);
4400
+ // } catch (err: unknown) {
4401
+ // if (isApiError(err, 'PRICES_UPDATED')) {
4402
+ // const { updatedCart } = err as { updatedCart: Cart };
4403
+ // showPricesUpdatedDialog(updatedCart); // show diff, let user confirm
4404
+ // return;
4405
+ // }
4406
+ // showGenericError(err);
4407
+ // }
4408
+ // }
4409
+ //
4410
+ // The dialog should:
4411
+ // 1. Show which items changed price (compare old vs new unitPrice)
4412
+ // 2. Offer "Continue with new prices" (calls createCheckoutSafe again)
4413
+ // and "Go back to cart" (returns to cart page)
4414
+ // 3. NOT silently retry \u2014 the customer must acknowledge the price change.`,
4415
+ "cart-item-modifier-display": `// Rendering cart items that have modifier selections.
4416
+ // Each cart item has a \`modifiers\` array \u2014 each entry records the modifier
4417
+ // name, the price delta at time of add-to-cart, and whether it was free
4418
+ // (inside the group's freeQuantity allowance).
4419
+ //
4420
+ // Typical display:
4421
+ // Margherita \u20AA45.00
4422
+ // Extra cheese +\u20AA5.00
4423
+ // Mushrooms free
4424
+ // No onions \u2014
4425
+ // \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
4426
+ // Base \u20AA45.00 + Modifiers \u20AA5.00 = \u20AA50.00
4427
+
4428
+ import { formatPrice } from 'brainerce';
4429
+
4430
+ interface CartItemModifier {
4431
+ modifierId: string;
4432
+ name: string;
4433
+ priceDeltaAtTime: number; // in store currency units, e.g. 5.00
4434
+ freeApplied: boolean; // true when inside the group's freeQuantity
4435
+ }
4436
+
4437
+ interface CartItem {
4438
+ id: string;
4439
+ name: string;
4440
+ unitPrice: number; // base + all chargeable modifier deltas, as stored
4441
+ quantity: number;
4442
+ modifiers?: CartItemModifier[];
4443
+ currency: string; // e.g. 'ILS', 'USD'
4444
+ }
4445
+
4446
+ function renderCartItem(item: CartItem): string {
4447
+ const currency = item.currency;
4448
+ const lines: string[] = [];
4449
+
4450
+ lines.push(\`\${item.name} \${formatPrice(item.unitPrice, { currency })}\`);
4451
+
4452
+ const chargeableModifiers = (item.modifiers ?? []).filter(
4453
+ (m) => !m.freeApplied && m.priceDeltaAtTime !== 0
4454
+ );
4455
+ const freeModifiers = (item.modifiers ?? []).filter((m) => m.freeApplied);
4456
+ const zeroModifiers = (item.modifiers ?? []).filter(
4457
+ (m) => !m.freeApplied && m.priceDeltaAtTime === 0
4458
+ );
4459
+
4460
+ for (const m of chargeableModifiers) {
4461
+ const sign = m.priceDeltaAtTime > 0 ? '+' : '';
4462
+ lines.push(\` \${m.name} \${sign}\${formatPrice(m.priceDeltaAtTime, { currency })}\`);
4463
+ }
4464
+ for (const m of freeModifiers) {
4465
+ lines.push(\` \${m.name} free\`);
4466
+ }
4467
+ for (const m of zeroModifiers) {
4468
+ lines.push(\` \${m.name} \u2014\`);
4469
+ }
4470
+
4471
+ // Price breakdown: only show when there are chargeable modifiers
4472
+ const modifiersTotal = chargeableModifiers.reduce(
4473
+ (sum, m) => sum + m.priceDeltaAtTime,
4474
+ 0
4475
+ );
4476
+ if (modifiersTotal !== 0) {
4477
+ // unitPrice already includes modifiers \u2014 derive base for display only
4478
+ const basePrice = item.unitPrice - modifiersTotal;
4479
+ const base = formatPrice(basePrice, { currency }) as string;
4480
+ const mods = formatPrice(modifiersTotal, { currency }) as string;
4481
+ const total = formatPrice(item.unitPrice, { currency }) as string;
4482
+ lines.push(\`Base \${base} + Modifiers \${mods} = \${total}\`);
4483
+ }
4484
+
4485
+ return lines.join('\\n');
4486
+ }
4487
+
4488
+ // IMPORTANT: unitPrice already includes all chargeable modifier deltas.
4489
+ // Do NOT add modifier prices on top of unitPrice when computing line totals \u2014
4490
+ // multiply unitPrice \xD7 quantity directly.
4491
+ function lineTotal(item: CartItem): number {
4492
+ return item.unitPrice * item.quantity;
4493
+ }`
3533
4494
  };
3534
4495
  async function handleGetCodeExample(args) {
3535
4496
  const snippet = SNIPPETS[args.operation];
@@ -3645,13 +4606,27 @@ function getCandidateApiUrls() {
3645
4606
 
3646
4607
  // src/tools/get-store-info.ts
3647
4608
  var GET_STORE_INFO_NAME = "get-store-info";
3648
- 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.";
4609
+ 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.";
3649
4610
  var GET_STORE_INFO_SCHEMA = {
3650
- connectionId: import_zod4.z.string().describe("Vibe-coded connection ID (starts with vc_)")
4611
+ salesChannelId: import_zod4.z.string().optional().describe("Sales channel ID (starts with vc_)"),
4612
+ /** @deprecated alias of salesChannelId */
4613
+ connectionId: import_zod4.z.string().optional().describe("Deprecated alias of salesChannelId \u2014 kept for backwards compat")
3651
4614
  };
3652
4615
  async function handleGetStoreInfo(args) {
4616
+ const id = args.salesChannelId ?? args.connectionId;
4617
+ if (!id) {
4618
+ return {
4619
+ content: [{ type: "text", text: "Error: salesChannelId is required" }],
4620
+ isError: true
4621
+ };
4622
+ }
4623
+ if (!args.salesChannelId && args.connectionId) {
4624
+ console.warn(
4625
+ "get-store-info: `connectionId` is deprecated \u2014 use `salesChannelId` instead"
4626
+ );
4627
+ }
3653
4628
  try {
3654
- const resolved = await resolveStoreInfo(args.connectionId, getCandidateApiUrls());
4629
+ const resolved = await resolveStoreInfo(id, getCandidateApiUrls());
3655
4630
  return {
3656
4631
  content: [
3657
4632
  {
@@ -3664,7 +4639,8 @@ async function handleGetStoreInfo(args) {
3664
4639
  storeName: resolved.info.storeName,
3665
4640
  currency: resolved.info.currency,
3666
4641
  language: resolved.info.language,
3667
- connectionId: args.connectionId,
4642
+ salesChannelId: id,
4643
+ connectionId: id,
3668
4644
  apiBaseUrl: resolved.apiBaseUrl
3669
4645
  },
3670
4646
  null,
@@ -3749,9 +4725,11 @@ async function resolveStoreCapabilities(connectionId, candidateUrls) {
3749
4725
 
3750
4726
  // src/tools/get-store-capabilities.ts
3751
4727
  var GET_STORE_CAPABILITIES_NAME = "get-store-capabilities";
3752
- 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.";
4728
+ 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.";
3753
4729
  var GET_STORE_CAPABILITIES_SCHEMA = {
3754
- connectionId: import_zod5.z.string().describe("Vibe-coded connection ID (starts with vc_)")
4730
+ salesChannelId: import_zod5.z.string().optional().describe("Sales channel ID (starts with vc_)"),
4731
+ /** @deprecated alias of salesChannelId */
4732
+ connectionId: import_zod5.z.string().optional().describe("Deprecated alias of salesChannelId \u2014 kept for backwards compat")
3755
4733
  };
3756
4734
  function formatCapabilities(caps) {
3757
4735
  const lines = [];
@@ -3864,15 +4842,27 @@ function formatCapabilities(caps) {
3864
4842
  }
3865
4843
  if (suggestions.length === 0) {
3866
4844
  suggestions.push(
3867
- "Start by building the required features. Use get-required-features with this connectionId for the checklist."
4845
+ "Start by building the required features. Use get-required-features with this salesChannelId for the checklist."
3868
4846
  );
3869
4847
  }
3870
4848
  suggestions.forEach((s) => lines.push(`- ${s}`));
3871
4849
  return lines.join("\n");
3872
4850
  }
3873
4851
  async function handleGetStoreCapabilities(args) {
4852
+ const id = args.salesChannelId ?? args.connectionId;
4853
+ if (!id) {
4854
+ return {
4855
+ content: [{ type: "text", text: "Error: salesChannelId is required" }],
4856
+ isError: true
4857
+ };
4858
+ }
4859
+ if (!args.salesChannelId && args.connectionId) {
4860
+ console.warn(
4861
+ "get-store-capabilities: `connectionId` is deprecated \u2014 use `salesChannelId` instead"
4862
+ );
4863
+ }
3874
4864
  try {
3875
- const resolved = await resolveStoreCapabilities(args.connectionId, getCandidateApiUrls());
4865
+ const resolved = await resolveStoreCapabilities(id, getCandidateApiUrls());
3876
4866
  return {
3877
4867
  content: [{ type: "text", text: formatCapabilities(resolved.capabilities) }]
3878
4868
  };
@@ -3977,7 +4967,7 @@ var RULES = {
3977
4967
  tokens: {
3978
4968
  title: "Auth tokens & BFF pattern",
3979
4969
  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\`.
3980
- - NEVER put the admin API key (\`brainerce_*\`) in client code. It is a server-only secret. Client code uses \`connectionId\` or storefront endpoints.
4970
+ - 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.
3981
4971
  - 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.
3982
4972
  - 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.`
3983
4973
  },
@@ -4270,18 +5260,20 @@ ${flow.body}`
4270
5260
  // src/tools/get-required-features.ts
4271
5261
  var import_zod9 = require("zod");
4272
5262
  var GET_REQUIRED_FEATURES_NAME = "get-required-features";
4273
- 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.";
5263
+ 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.";
4274
5264
  var GET_REQUIRED_FEATURES_SCHEMA = {
4275
- connectionId: import_zod9.z.string().optional().describe(
4276
- "Vibe-coded connection ID (starts with vc_). Optional \u2014 without it, returns the generic checklist."
4277
- )
5265
+ salesChannelId: import_zod9.z.string().optional().describe(
5266
+ "Sales channel ID (starts with vc_). Optional \u2014 without it, returns the generic checklist."
5267
+ ),
5268
+ /** @deprecated alias of salesChannelId */
5269
+ connectionId: import_zod9.z.string().optional().describe("Deprecated alias of salesChannelId \u2014 kept for backwards compat")
4278
5270
  };
4279
5271
  var FEATURES = [
4280
5272
  {
4281
5273
  id: "browse-products",
4282
5274
  title: "Browse, filter, and search products",
4283
- 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.",
4284
- sdk: "client.getProducts({ page, limit, filters, sort }), client.searchProducts(query)",
5275
+ 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.",
5276
+ sdk: "client.getProducts({ page, limit, filters, sort, metafields }), client.getPublicMetafieldDefinitions(), client.searchProducts(query)",
4285
5277
  mandatory: "mandatory"
4286
5278
  },
4287
5279
  {
@@ -4496,10 +5488,16 @@ function renderCapabilitiesSummary(caps) {
4496
5488
  return lines.join("\n");
4497
5489
  }
4498
5490
  async function handleGetRequiredFeatures(args) {
5491
+ const id = args.salesChannelId ?? args.connectionId;
5492
+ if (!args.salesChannelId && args.connectionId) {
5493
+ console.warn(
5494
+ "get-required-features: `connectionId` is deprecated \u2014 use `salesChannelId` instead"
5495
+ );
5496
+ }
4499
5497
  let caps = null;
4500
- if (args.connectionId) {
5498
+ if (id) {
4501
5499
  try {
4502
- const resolved = await resolveStoreCapabilities(args.connectionId, getCandidateApiUrls());
5500
+ const resolved = await resolveStoreCapabilities(id, getCandidateApiUrls());
4503
5501
  caps = resolved.capabilities;
4504
5502
  } catch {
4505
5503
  caps = null;
@@ -4511,11 +5509,11 @@ async function handleGetRequiredFeatures(args) {
4511
5509
  );
4512
5510
  if (caps) {
4513
5511
  sections.push(renderCapabilitiesSummary(caps));
4514
- } else if (args.connectionId) {
5512
+ } else if (id) {
4515
5513
  sections.push(
4516
5514
  `## Live store capabilities
4517
5515
 
4518
- Could not fetch live capabilities for \`${args.connectionId}\`. Proceeding with the generic checklist. Call \`get-store-capabilities\` separately if you want a targeted checklist.`
5516
+ Could not fetch live capabilities for \`${id}\`. Proceeding with the generic checklist. Call \`get-store-capabilities\` separately if you want a targeted checklist.`
4519
5517
  );
4520
5518
  }
4521
5519
  sections.push("## Features");