@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/bin/http.js +1429 -58
- package/dist/bin/stdio.js +1429 -58
- package/dist/index.js +1429 -58
- package/dist/index.mjs +1429 -58
- package/package.json +1 -1
package/dist/bin/http.js
CHANGED
|
@@ -267,9 +267,14 @@ function CheckoutPage() {
|
|
|
267
267
|
}
|
|
268
268
|
|
|
269
269
|
// Step 4: Create payment intent \u2014 returns provider type!
|
|
270
|
+
// Pass saveCard: true when the customer ticked "save my card for next time" \u2014
|
|
271
|
+
// only honored for logged-in customers (not guests). The vaulted card then
|
|
272
|
+
// appears in client.listSavedPaymentMethods(storeId, customerId) and can be
|
|
273
|
+
// charged off-session via subscription / one-click checkout flows.
|
|
270
274
|
const paymentIntent = await client.createPaymentIntent(checkoutId, {
|
|
271
275
|
successUrl: \`\${window.location.origin}/order-confirmation?checkout_id=\${checkoutId}\`,
|
|
272
276
|
cancelUrl: \`\${window.location.origin}/checkout?error=payment_cancelled\`,
|
|
277
|
+
// saveCard: customerOptedIn, // optional opt-in
|
|
273
278
|
});
|
|
274
279
|
|
|
275
280
|
setPaymentData({ clientSecret: paymentIntent.clientSecret, provider: paymentIntent.provider, checkoutId });
|
|
@@ -390,8 +395,10 @@ function PaymentIframe({ clientSecret, checkoutId }: { clientSecret: string; che
|
|
|
390
395
|
}
|
|
391
396
|
if (data?.type === 'brainerce:redirect' && typeof data.url === 'string') {
|
|
392
397
|
// Top-level navigation (e.g. Bit). ALWAYS validate against an allowlist
|
|
393
|
-
// before navigating \u2014 never trust the URL blindly.
|
|
394
|
-
|
|
398
|
+
// before navigating \u2014 never trust the URL blindly. The SDK ships with
|
|
399
|
+
// a maintained list of payment-provider hosts; prefer it over a local
|
|
400
|
+
// copy so 'npm update brainerce' picks up new providers automatically.
|
|
401
|
+
if (isAllowedPaymentUrl(data.url)) { window.top!.location.href = data.url; }
|
|
395
402
|
}
|
|
396
403
|
if (data?.type === 'brainerce:payment-complete') {
|
|
397
404
|
// Payment done \u2014 redirect to confirmation page which verifies server-side
|
|
@@ -431,15 +438,10 @@ function PaymentIframe({ clientSecret, checkoutId }: { clientSecret: string; che
|
|
|
431
438
|
);
|
|
432
439
|
}
|
|
433
440
|
|
|
434
|
-
// Allowlist
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
if (u.protocol !== 'https:') return false;
|
|
439
|
-
// Add your provider hostnames here. Example: Cardcom for Bit express-pay.
|
|
440
|
-
return u.hostname === 'cardcom.solutions' || u.hostname.endsWith('.cardcom.solutions');
|
|
441
|
-
} catch { return false; }
|
|
442
|
-
}
|
|
441
|
+
// Allowlist check is provided by the SDK \u2014 covers Stripe, PayPal, Cardcom,
|
|
442
|
+
// Meshulam, Grow, CreditGuard, plus Brainerce-hosted embed shells. To extend
|
|
443
|
+
// for a self-hosted PSP, pass { extraHosts: ['my-psp.example.com'] }.
|
|
444
|
+
import { isAllowedPaymentUrl } from 'brainerce';
|
|
443
445
|
\`\`\`
|
|
444
446
|
|
|
445
447
|
### PayPal Payment Form
|
|
@@ -727,6 +729,80 @@ Provider-specific install notes:
|
|
|
727
729
|
- **Grow:** No SDK needed \u2014 JS SDK loaded via \`clientSdk.scriptUrl\`. Supports credit cards, Bit, Apple Pay, Google Pay.
|
|
728
730
|
- **Cardcom:** No SDK needed \u2014 rendering driven by \`renderType: 'iframe'\` + the inline/modal branch above. Supports credit cards, Bit (when terminal provisions it), installments, 3D Secure.`;
|
|
729
731
|
}
|
|
732
|
+
function getSavedPaymentMethodsSection() {
|
|
733
|
+
return `## Saved Payment Methods (vaulted cards)
|
|
734
|
+
|
|
735
|
+
When a logged-in customer ticks "save my card for next time", the platform vaults the
|
|
736
|
+
card with the underlying provider and stores a reference. Vaulted cards can later be
|
|
737
|
+
charged off-session \u2014 typically via subscription / one-click checkout features built
|
|
738
|
+
on top of this foundation.
|
|
739
|
+
|
|
740
|
+
### Opt-in at checkout
|
|
741
|
+
|
|
742
|
+
Pass \`saveCard: true\` to \`createPaymentIntent\` when the customer chose to save.
|
|
743
|
+
**Only honored for logged-in customers** \u2014 anonymous (guest) checkouts silently
|
|
744
|
+
skip vaulting because there's no Customer record to attach the resulting saved
|
|
745
|
+
method to.
|
|
746
|
+
|
|
747
|
+
\`\`\`typescript
|
|
748
|
+
const intent = await client.createPaymentIntent(checkoutId, {
|
|
749
|
+
successUrl,
|
|
750
|
+
cancelUrl,
|
|
751
|
+
saveCard: true, // optional \u2014 only when customer ticked the box AND is logged in
|
|
752
|
+
});
|
|
753
|
+
\`\`\`
|
|
754
|
+
|
|
755
|
+
After a successful charge, the saved card appears in the customer's profile.
|
|
756
|
+
|
|
757
|
+
### Listing saved cards (storefront / customer account page)
|
|
758
|
+
|
|
759
|
+
Show the customer their saved cards on a "Manage Payment Methods" page in their
|
|
760
|
+
account. The platform returns display-only metadata \u2014 last4, brand, expiry,
|
|
761
|
+
default flag, status. The underlying provider token is encrypted at rest and
|
|
762
|
+
NEVER returned through the SDK.
|
|
763
|
+
|
|
764
|
+
\`\`\`typescript
|
|
765
|
+
const methods = await client.listSavedPaymentMethods(storeId, customerId);
|
|
766
|
+
|
|
767
|
+
methods.forEach((m) => {
|
|
768
|
+
console.log(\`\${m.brand} ending in \${m.last4} (expires \${m.expMonth}/\${m.expYear})\`);
|
|
769
|
+
if (m.isDefault) console.log(' \u21B3 default');
|
|
770
|
+
if (m.status === 'expired') console.log(' \u21B3 expired \u2014 please add a new card');
|
|
771
|
+
});
|
|
772
|
+
\`\`\`
|
|
773
|
+
|
|
774
|
+
### Removing a saved card
|
|
775
|
+
|
|
776
|
+
\`\`\`typescript
|
|
777
|
+
await client.removeSavedPaymentMethod(storeId, customerId, methodId);
|
|
778
|
+
\`\`\`
|
|
779
|
+
|
|
780
|
+
Hard-deletes the row from the platform DB. The provider may still hold the
|
|
781
|
+
underlying token internally \u2014 we don't issue a delete-at-provider call because
|
|
782
|
+
not every provider supports it. From the platform's perspective the token is
|
|
783
|
+
gone; subsequent charges fail.
|
|
784
|
+
|
|
785
|
+
### Provider support matrix
|
|
786
|
+
|
|
787
|
+
| Provider | Save card on first charge | Charge saved card off-session |
|
|
788
|
+
|---|---|---|
|
|
789
|
+
| Cardcom | \u2705 (Operation: ChargeAndCreateToken) | \u2705 |
|
|
790
|
+
| PayPal | \u2705 (Vault API: store_in_vault) | \u2705 |
|
|
791
|
+
| Stripe | (separate workstream \u2014 Brainerce doesn't ship a Stripe payment app yet) | \u2014 |
|
|
792
|
+
| Grow | \u274C \u2014 returns 501 \`token_storage_unavailable\` | \u274C |
|
|
793
|
+
|
|
794
|
+
When a provider doesn't support tokenization, the platform surfaces a 409 with
|
|
795
|
+
\`code: 'token_storage_unavailable'\`. Storefronts should hide the "save card"
|
|
796
|
+
checkbox when the active provider doesn't support it.
|
|
797
|
+
|
|
798
|
+
### Charging off-session
|
|
799
|
+
|
|
800
|
+
Charging a saved card from the storefront is **not** part of this foundation \u2014
|
|
801
|
+
that's the job of the Subscription / one-click checkout features built on top.
|
|
802
|
+
For now, charges run through the standard \`createPaymentIntent\` flow. The
|
|
803
|
+
saved-method foundation makes those features possible without further platform
|
|
804
|
+
changes.`;
|
|
805
|
+
}
|
|
730
806
|
function getProductsSection(_currency) {
|
|
731
807
|
return `## Products & Variants
|
|
732
808
|
|
|
@@ -747,6 +823,18 @@ const filtered = await client.getProducts({
|
|
|
747
823
|
categories: ['cat_123'], minPrice: 10, maxPrice: 100,
|
|
748
824
|
sortBy: 'price', sortOrder: 'asc',
|
|
749
825
|
});
|
|
826
|
+
|
|
827
|
+
// Filter by custom fields (metafields). Only fields the merchant marked
|
|
828
|
+
// \`filterable: true\` are honored; supported types are SELECT, MULTI_SELECT,
|
|
829
|
+
// BOOLEAN. AND across keys, OR within a key.
|
|
830
|
+
const byCustom = await client.getProducts({
|
|
831
|
+
metafields: { color: ['red', 'blue'], in_stock: ['true'] },
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
// Discover which custom fields are filterable for the current store:
|
|
835
|
+
const { definitions } = await client.getPublicMetafieldDefinitions();
|
|
836
|
+
const facets = definitions.filter(d => d.filterable);
|
|
837
|
+
// Render a checkbox group per SELECT/MULTI_SELECT, a switch per BOOLEAN.
|
|
750
838
|
\`\`\`
|
|
751
839
|
|
|
752
840
|
### i18n \u2014 translated fields come back on every request
|
|
@@ -1116,22 +1204,24 @@ const { upgrades } = await client.getCartUpgrades(cartId);
|
|
|
1116
1204
|
// Show inline banner per cart item if upgrade exists
|
|
1117
1205
|
\`\`\`
|
|
1118
1206
|
|
|
1119
|
-
**Bundle offers** (
|
|
1207
|
+
**Bundle offers** (N-product bundles configured by the store owner \u2014 productIds[0] triggers the offer, productIds[1..] are offered together at a discount):
|
|
1120
1208
|
\`\`\`typescript
|
|
1121
1209
|
import type { CartBundlesResponse } from 'brainerce';
|
|
1122
1210
|
const { bundles } = await client.getCartBundles(cartId);
|
|
1123
|
-
// bundles[].
|
|
1124
|
-
// bundles[].
|
|
1211
|
+
// bundles[].triggerProductId \u2014 already in cart, activates the offer
|
|
1212
|
+
// bundles[].productIds \u2014 full bundle composition (length >= 2)
|
|
1213
|
+
// bundles[].offeredProducts[] \u2014 products customer hasn't added yet
|
|
1214
|
+
// each with originalPrice + discountedPrice
|
|
1215
|
+
// bundles[].totalOriginalPrice / totalDiscountedPrice \u2014 sums across offeredProducts
|
|
1125
1216
|
// bundles[].discountType ('PERCENTAGE' | 'FIXED_AMOUNT') + discountValue
|
|
1126
|
-
// bundles[].requiresVariantSelection \u2014 true if customer must pick a variant
|
|
1127
|
-
// bundles[].lockedVariant \u2014 set if admin pre-selected a specific variant
|
|
1128
|
-
// bundles[].bundleProduct.variants \u2014 available variants (only when requiresVariantSelection)
|
|
1129
1217
|
|
|
1130
|
-
//
|
|
1218
|
+
// Accept a bundle (adds every offered product not yet in cart at the discount):
|
|
1131
1219
|
await client.addBundleToCart(cartId, bundleOfferId);
|
|
1132
|
-
//
|
|
1133
|
-
await client.addBundleToCart(cartId, bundleOfferId,
|
|
1134
|
-
|
|
1220
|
+
// If some offered products have variants, pass per-product variant selections:
|
|
1221
|
+
await client.addBundleToCart(cartId, bundleOfferId, {
|
|
1222
|
+
[variantProductId]: selectedVariantId,
|
|
1223
|
+
});
|
|
1224
|
+
// Remove an accepted bundle (removes every cart item linked to that bundle):
|
|
1135
1225
|
await client.removeBundleFromCart(cartId, bundleOfferId);
|
|
1136
1226
|
// Detect already-added bundles: check cart.items for metadata?.isBundleItem === true
|
|
1137
1227
|
\`\`\`
|
|
@@ -1187,11 +1277,12 @@ Always check the flag before rendering: \`if (storeInfo?.upsell?.featureName !==
|
|
|
1187
1277
|
| CartRecommendationSection | products/ | Cross-sell grid at bottom of cart page |
|
|
1188
1278
|
| FreeShippingBar | cart/ | Progress bar toward free shipping threshold |
|
|
1189
1279
|
| CartUpgradeBanner | cart/ | Inline "Upgrade to X for +$Y" per cart item |
|
|
1190
|
-
| CartBundleOfferCard | cart/ |
|
|
1280
|
+
| CartBundleOfferCard | cart/ | N-product bundle offer card in cart \u2014 lists every offered product with its discounted price and an "Add bundle" button |
|
|
1191
1281
|
| OrderBumpCard | checkout/ | Checkbox add-on card in checkout sidebar (with inline variant selector for variable products) |
|
|
1192
1282
|
|
|
1193
1283
|
ProductRecommendation: \`id\`, \`name\`, \`slug\`, \`basePrice\`, \`salePrice\`, \`images\`, \`type\`, \`inventory\`, \`relationType\`, \`variants?\` (when variant selection needed).
|
|
1194
|
-
OrderBump
|
|
1284
|
+
OrderBump: includes \`requiresVariantSelection\` (boolean) and \`lockedVariant?\` (\`{ id, name, attributes }\`).
|
|
1285
|
+
CartBundleOffer: \`productIds\` (length \\>= 2; index 0 = trigger), \`offeredProducts\` (per-product discounted prices), \`totalOriginalPrice\` / \`totalDiscountedPrice\`. Variant selection for offered products is passed per-call via \`variantSelections\` on \`addBundleToCart\`.`;
|
|
1195
1286
|
}
|
|
1196
1287
|
function getProductCustomizationFieldsSection() {
|
|
1197
1288
|
return `## Product Customization Fields (buyer input on product page)
|
|
@@ -1297,6 +1388,157 @@ When the order is created, each line's customization values are snapshotted onto
|
|
|
1297
1388
|
- Using a raw external URL (not from \`/customization-upload\`) for \`IMAGE\` / \`GALLERY\` \u2192 HTTP 400
|
|
1298
1389
|
- Assuming \`enumValues\` is present for all types \u2014 only \`SELECT\` / \`MULTI_SELECT\` require it`;
|
|
1299
1390
|
}
|
|
1391
|
+
function getModifierGroupsSection() {
|
|
1392
|
+
return `## Modifier Groups (toppings, sauce, build-your-own)
|
|
1393
|
+
|
|
1394
|
+
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.
|
|
1395
|
+
|
|
1396
|
+
If \`product.modifierGroups\` is empty or missing, render the product page normally and skip everything below.
|
|
1397
|
+
|
|
1398
|
+
### Wire shape on \`GET /products/:id\`
|
|
1399
|
+
|
|
1400
|
+
\`\`\`typescript
|
|
1401
|
+
import type { Product, ModifierGroup, Modifier, ModifierSelection } from 'brainerce';
|
|
1402
|
+
|
|
1403
|
+
const product = await client.getProductBySlug(slug);
|
|
1404
|
+
const groups: ModifierGroup[] = product.modifierGroups ?? [];
|
|
1405
|
+
|
|
1406
|
+
// Each group looks like:
|
|
1407
|
+
// {
|
|
1408
|
+
// id: 'mg_toppings',
|
|
1409
|
+
// attachmentId: 'pmg_01', // ProductModifierGroup row \u2014 used for attach updates
|
|
1410
|
+
// name: 'Toppings', // customer-facing
|
|
1411
|
+
// internalName?: string, // ADMIN ONLY \u2014 never present in storefront responses
|
|
1412
|
+
// selectionType: 'SINGLE' | 'MULTIPLE',
|
|
1413
|
+
// min: number, // effective (overrides already applied)
|
|
1414
|
+
// max: number | null, // null = unlimited; **0 = group hidden for this variant**
|
|
1415
|
+
// freeQuantity: number, // first N picks at no extra cost
|
|
1416
|
+
// required: boolean,
|
|
1417
|
+
// freeAllocationPolicy: 'EXPENSIVE_FREE' | 'CHEAPEST_FREE' | 'SELECTION_ORDER',
|
|
1418
|
+
// defaultModifierIds: string[], // pre-checked on first render
|
|
1419
|
+
// modifiers: Array<{
|
|
1420
|
+
// id: 'm_olive',
|
|
1421
|
+
// name: 'Olives',
|
|
1422
|
+
// priceDelta: '5.00', // DECIMAL STRING \u2014 never JSON Number
|
|
1423
|
+
// position: 0,
|
|
1424
|
+
// isDefault: false, // pre-check this option even if not in defaultModifierIds
|
|
1425
|
+
// available: true, // false \u2192 "sold out", disabled in UI
|
|
1426
|
+
// referencedProductId?: string, // nested-combo target (depth \u2264 3)
|
|
1427
|
+
// }>,
|
|
1428
|
+
// }
|
|
1429
|
+
\`\`\`
|
|
1430
|
+
|
|
1431
|
+
**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.
|
|
1432
|
+
|
|
1433
|
+
### Render: SINGLE \u2192 radio, MULTIPLE \u2192 checkbox
|
|
1434
|
+
|
|
1435
|
+
Walk \`groups\` and pick the input type by \`selectionType\`. Sort modifiers by \`position\`.
|
|
1436
|
+
|
|
1437
|
+
\`\`\`typescript
|
|
1438
|
+
for (const group of groups) {
|
|
1439
|
+
if (group.max === 0) continue; // disabled-for-variant convention \u2014 skip entirely
|
|
1440
|
+
const inputType = group.selectionType === 'SINGLE' ? 'radio' : 'checkbox';
|
|
1441
|
+
const sorted = [...group.modifiers].sort((a, b) => a.position - b.position);
|
|
1442
|
+
// Render fieldset \u2192 legend with name + " *" if required \u2192 list of sorted options.
|
|
1443
|
+
// Each option: input(type=inputType, name=group.id, value=modifier.id),
|
|
1444
|
+
// disabled when modifier.available === false, with a "Sold out" badge.
|
|
1445
|
+
}
|
|
1446
|
+
\`\`\`
|
|
1447
|
+
|
|
1448
|
+
When \`group.freeQuantity > 0\`, show a running counter so the customer understands the rule:
|
|
1449
|
+
\`\`\`
|
|
1450
|
+
{Math.min(picks.length, group.freeQuantity)} of {group.freeQuantity} free
|
|
1451
|
+
\`\`\`
|
|
1452
|
+
|
|
1453
|
+
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.
|
|
1454
|
+
|
|
1455
|
+
### Initial state
|
|
1456
|
+
|
|
1457
|
+
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.
|
|
1458
|
+
|
|
1459
|
+
### Pass selections on add-to-cart
|
|
1460
|
+
|
|
1461
|
+
\`\`\`typescript
|
|
1462
|
+
const selections: ModifierSelection[] = [
|
|
1463
|
+
{ modifierGroupId: 'mg_bread', modifierIds: ['m_thick'] },
|
|
1464
|
+
{ modifierGroupId: 'mg_toppings', modifierIds: ['m_olive', 'm_mushroom', 'm_bacon', 'm_egg'] },
|
|
1465
|
+
];
|
|
1466
|
+
|
|
1467
|
+
await client.smartAddToCart({
|
|
1468
|
+
productId: product.id,
|
|
1469
|
+
variantId: selectedVariant?.id,
|
|
1470
|
+
quantity: 1,
|
|
1471
|
+
selections,
|
|
1472
|
+
});
|
|
1473
|
+
\`\`\`
|
|
1474
|
+
|
|
1475
|
+
\`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:
|
|
1476
|
+
|
|
1477
|
+
\`\`\`typescript
|
|
1478
|
+
// cart.items[i].modifiers \u2014 same shape as ModifierSelection but with snapshot data
|
|
1479
|
+
[
|
|
1480
|
+
{ modifierId: 'm_olive', name: 'Olives', priceDelta: '5.00', freeApplied: true },
|
|
1481
|
+
{ modifierId: 'm_mushroom', name: 'Mushrooms', priceDelta: '5.00', freeApplied: true },
|
|
1482
|
+
{ modifierId: 'm_bacon', name: 'Bacon', priceDelta: '7.00', freeApplied: true },
|
|
1483
|
+
{ modifierId: 'm_egg', name: 'Egg', priceDelta: '6.00', freeApplied: false },
|
|
1484
|
+
]
|
|
1485
|
+
// + cart.items[i].modifiersTotal \u2014 sum of paid (non-free) deltas as a decimal string
|
|
1486
|
+
\`\`\`
|
|
1487
|
+
|
|
1488
|
+
\`freeApplied: true\` means the modifier consumed a free slot \u2014 render with a "free" badge in the cart UI.
|
|
1489
|
+
|
|
1490
|
+
### Editing selections after add-to-cart (idempotent)
|
|
1491
|
+
|
|
1492
|
+
\`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).
|
|
1493
|
+
|
|
1494
|
+
\`\`\`typescript
|
|
1495
|
+
await client.updateCartItem(cart.id, itemId, {
|
|
1496
|
+
quantity: 1,
|
|
1497
|
+
selections: [{ modifierGroupId: 'mg_toppings', modifierIds: ['m_olive', 'm_mushroom'] }],
|
|
1498
|
+
});
|
|
1499
|
+
\`\`\`
|
|
1500
|
+
|
|
1501
|
+
### Validation envelope
|
|
1502
|
+
|
|
1503
|
+
When the payload is invalid the server returns HTTP 400 with a structured envelope. The SDK exposes it on \`BrainerceError.details\`:
|
|
1504
|
+
|
|
1505
|
+
\`\`\`typescript
|
|
1506
|
+
try {
|
|
1507
|
+
await client.smartAddToCart({ productId, quantity: 1, selections });
|
|
1508
|
+
} catch (err) {
|
|
1509
|
+
const e = err as { statusCode?: number; details?: { code?: string; errors?: Array<{ code: string; message: string; modifierGroupId?: string; modifierId?: string }> } };
|
|
1510
|
+
if (e.statusCode === 400 && e.details?.code === 'MODIFIER_VALIDATION_FAILED') {
|
|
1511
|
+
for (const issue of e.details.errors ?? []) {
|
|
1512
|
+
// issue.code is one of: REQUIRED_GROUP_MISSING | MIN_SELECTIONS_NOT_MET |
|
|
1513
|
+
// MAX_SELECTIONS_EXCEEDED | SINGLE_GROUP_MULTIPLE_PICKS | UNKNOWN_MODIFIER |
|
|
1514
|
+
// UNKNOWN_GROUP | MODIFIER_DISABLED_FOR_VARIANT | MODIFIER_NOT_AVAILABLE |
|
|
1515
|
+
// NESTED_DEPTH_EXCEEDED | NESTED_REQUIRES_PRODUCT_REF | INVALID_PRICE_DELTA |
|
|
1516
|
+
// MODIFIER_PRICE_FLOOR_VIOLATED
|
|
1517
|
+
console.error(issue.message);
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
\`\`\`
|
|
1522
|
+
|
|
1523
|
+
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.
|
|
1524
|
+
|
|
1525
|
+
### Disable-for-variant convention (PRD \xA77.2.2)
|
|
1526
|
+
|
|
1527
|
+
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.
|
|
1528
|
+
|
|
1529
|
+
### Common mistakes
|
|
1530
|
+
|
|
1531
|
+
- Treating \`priceDelta\` as a number \u2192 arithmetic precision bugs. Always strings; use \`parseFloat\` only at display time.
|
|
1532
|
+
- Computing the line total client-side \u2192 diverges from the server's free-allocation. Render the server's \`unitPrice\` / \`modifiers[]\` / \`modifiersTotal\` instead.
|
|
1533
|
+
- Rendering a group with \`max: 0\` \u2192 it's the variant-disable signal; treat as absent.
|
|
1534
|
+
- 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.
|
|
1535
|
+
- Building \`selections\` keyed by \`modifier.name\` \u2192 must be \`modifierGroupId\` + \`modifierIds[]\` (the IDs, not names).
|
|
1536
|
+
- Sending \`modifiers\` instead of \`selections\` on add-to-cart \u2192 \`modifiers\` is the response field; the request key is \`selections\`.
|
|
1537
|
+
|
|
1538
|
+
### Restaurant features (advanced)
|
|
1539
|
+
|
|
1540
|
+
Allergens (informational chips), scheduled availability windows, nested combos (depth \u2264 3 via \`nestedByModifierId\`), and downsell modifiers (negative \`priceDelta\`) are all built on the same data model. See INTEGRATION-OPTIONAL.md "Restaurant / build-your-own products" for those flows.`;
|
|
1541
|
+
}
|
|
1300
1542
|
function getInventorySection() {
|
|
1301
1543
|
return `## Inventory, Stock Display & Reservation Countdown
|
|
1302
1544
|
|
|
@@ -1862,7 +2104,358 @@ const admin = new BrainerceClient({ apiKey: 'brainerce_...' });
|
|
|
1862
2104
|
// Email: getEmailTemplates(), createEmailTemplate()
|
|
1863
2105
|
// Conflicts: getSyncConflicts(), resolveSyncConflict()
|
|
1864
2106
|
// OAuth: getOAuthProviders(), configureOAuthProvider()
|
|
1865
|
-
|
|
2107
|
+
\`\`\`
|
|
2108
|
+
|
|
2109
|
+
### Per-Channel Publishing
|
|
2110
|
+
|
|
2111
|
+
Categories, tags, brands, and metafield definitions are gated to specific
|
|
2112
|
+
vibe-coded sites \u2014 same control already exposed for products and coupons.
|
|
2113
|
+
**Explicit opt-in:** an entity is visible to a vibe-coded site only if it
|
|
2114
|
+
has been explicitly published to that connection. Entities with no publish
|
|
2115
|
+
rows are invisible to every site (the merchant must publish them via the
|
|
2116
|
+
dashboard or the admin SDK).
|
|
2117
|
+
|
|
2118
|
+
\`\`\`typescript
|
|
2119
|
+
// Publish to a specific vibe-coded site (admin mode):
|
|
2120
|
+
await admin.publishCategoryToVibeCodedSite('cat_id', 'conn_id');
|
|
2121
|
+
await admin.publishTagToVibeCodedSite('tag_id', 'conn_id');
|
|
2122
|
+
await admin.publishBrandToVibeCodedSite('brand_id', 'conn_id');
|
|
2123
|
+
await admin.publishMetafieldDefinitionToVibeCodedSite('def_id', 'conn_id');
|
|
2124
|
+
|
|
2125
|
+
// Unpublish (entity stays visible to other sites unless they also have publishes):
|
|
2126
|
+
await admin.unpublishCategoryFromVibeCodedSite('cat_id', 'conn_id');
|
|
2127
|
+
// ...same for tag/brand/metafield definition.
|
|
2128
|
+
|
|
2129
|
+
// Read which sites an entity is published to via list/get responses:
|
|
2130
|
+
const cat = await admin.getCategory('cat_id');
|
|
2131
|
+
cat.vibeCodedPublishes; // [{ connection: { id, name, connectionId } }, ...]
|
|
2132
|
+
\`\`\`
|
|
2133
|
+
|
|
2134
|
+
Cross-account isolation is enforced server-side: a publish call only
|
|
2135
|
+
succeeds when entity and connection belong to the same account. Cross-account
|
|
2136
|
+
calls fail with \`404 Not Found\`.
|
|
2137
|
+
|
|
2138
|
+
The vibe-coded read endpoints (used by storefront SDK calls in \`connectionId\`
|
|
2139
|
+
mode) automatically filter by these junction tables, so storefronts never see
|
|
2140
|
+
entities that weren't published to their site.`;
|
|
2141
|
+
}
|
|
2142
|
+
function getContactInquiriesSection() {
|
|
2143
|
+
return `## Contact Inquiries & Forms (optional)
|
|
2144
|
+
|
|
2145
|
+
Accept messages from one or more contact forms on your storefront. The merchant
|
|
2146
|
+
configures everything in the Brainerce dashboard under **Customers \u2192 Contact
|
|
2147
|
+
Forms**; submissions land in **Customers \u2192 Inquiries** and both sides receive
|
|
2148
|
+
email notifications.
|
|
2149
|
+
|
|
2150
|
+
**IMPORTANT for AI code generators:** the merchant OWNS the form shape. Do not
|
|
2151
|
+
hardcode labels, placeholders, help text, the submit button, or the success
|
|
2152
|
+
message. Render from the schema returned by \`contactForms.get()\` so the
|
|
2153
|
+
storefront reflects whatever the merchant configures \u2014 including new fields
|
|
2154
|
+
they add later, translations they add later, and the success message they
|
|
2155
|
+
write. This is the difference between "a contact form" and "THE merchant's
|
|
2156
|
+
contact form."
|
|
2157
|
+
|
|
2158
|
+
### Two integration paths
|
|
2159
|
+
|
|
2160
|
+
1. **Simple (legacy)** \u2014 pass \`{ name, email, subject, message }\` and submit.
|
|
2161
|
+
Works out of the box, no dashboard setup needed. Backed by a lazily-seeded
|
|
2162
|
+
default form keyed \`"main"\` with five built-in fields (\`name\`, \`email\`,
|
|
2163
|
+
\`phone\`, \`subject\`, \`message\`). Choose this only for throwaway demos.
|
|
2164
|
+
2. **Flexible (recommended, default in \`npx create-brainerce-store\`)** \u2014
|
|
2165
|
+
merchants can configure multiple forms per store (e.g. \`main\`,
|
|
2166
|
+
\`newsletter\`, \`whatsapp_prechat\`), add custom fields (TEXT, TEXTAREA,
|
|
2167
|
+
EMAIL, PHONE, NUMBER, SELECT, MULTI_SELECT, CHECKBOX, URL, DATE), translate
|
|
2168
|
+
everything per locale, hide any non-essential built-in. Fetch the schema
|
|
2169
|
+
with \`contactForms.get(formKey, locale)\` and render dynamically.
|
|
2170
|
+
|
|
2171
|
+
### Simple path (legacy shape)
|
|
2172
|
+
|
|
2173
|
+
\`\`\`typescript
|
|
2174
|
+
import type { CreateInquiryInput, CreateInquiryResponse } from 'brainerce';
|
|
2175
|
+
|
|
2176
|
+
const result = await brainerce.createInquiry({
|
|
2177
|
+
name: 'Jane Doe',
|
|
2178
|
+
email: 'jane@example.com',
|
|
2179
|
+
subject: 'Question about shipping',
|
|
2180
|
+
message: 'Hi, do you ship internationally?',
|
|
2181
|
+
phone: '+1-555-0100', // optional
|
|
2182
|
+
customerId: customer?.id, // optional \u2014 link to logged-in customer
|
|
2183
|
+
metadata: { page: '/contact' }, // optional
|
|
2184
|
+
});
|
|
2185
|
+
// \u2192 { id, status: 'NEW', createdAt }
|
|
2186
|
+
\`\`\`
|
|
2187
|
+
|
|
2188
|
+
### Flexible path \u2014 end-to-end contract
|
|
2189
|
+
|
|
2190
|
+
**Endpoints:**
|
|
2191
|
+
|
|
2192
|
+
| Method | Path | Returns |
|
|
2193
|
+
| ------ | ----------------------------------------------------------------- | ------------------------------ |
|
|
2194
|
+
| GET | \`/stores/:storeId/contact-forms\` | \`ContactFormSummary[]\` |
|
|
2195
|
+
| GET | \`/stores/:storeId/contact-forms/:formKey?locale=xx\` | \`ContactFormPublic\` |
|
|
2196
|
+
| POST | \`/stores/:storeId/inquiries\` | \`CreateInquiryResponse\` |
|
|
2197
|
+
|
|
2198
|
+
All three are public (no API key). The SDK wraps them so you never call them
|
|
2199
|
+
directly \u2014 use \`brainerce.contactForms.list()\`, \`brainerce.contactForms.get()\`,
|
|
2200
|
+
and \`brainerce.createInquiry()\`.
|
|
2201
|
+
|
|
2202
|
+
\`\`\`typescript
|
|
2203
|
+
import type { ContactFormPublic } from 'brainerce';
|
|
2204
|
+
|
|
2205
|
+
// List active forms (optional \u2014 if you want a form picker or route-per-form setup)
|
|
2206
|
+
const forms = await brainerce.contactForms.list();
|
|
2207
|
+
// \u2192 [{ key: 'main', name: 'Contact us', isDefault: true }, ...]
|
|
2208
|
+
|
|
2209
|
+
// Fetch one form's schema \u2014 server pre-resolves translations for the locale
|
|
2210
|
+
// and strips every field the merchant marked isVisible=false.
|
|
2211
|
+
const form = await brainerce.contactForms.get('main', 'en');
|
|
2212
|
+
// \u2192 {
|
|
2213
|
+
// id, key, name, description?, submitButton, successMessage,
|
|
2214
|
+
// fields: [{ key, type, label, placeholder?, helpText?, isRequired,
|
|
2215
|
+
// enumValues?, validation?, defaultValue?, width? }, ...]
|
|
2216
|
+
// }
|
|
2217
|
+
|
|
2218
|
+
// Submit a keyed payload. Unknown keys are stripped, required fields are
|
|
2219
|
+
// enforced, and every value is validated against \`validation\` server-side.
|
|
2220
|
+
await brainerce.createInquiry({
|
|
2221
|
+
formKey: 'main',
|
|
2222
|
+
fields: {
|
|
2223
|
+
email: 'jane@example.com', // built-in keys
|
|
2224
|
+
message: 'Hi...',
|
|
2225
|
+
// ...any custom keys the merchant added via the dashboard
|
|
2226
|
+
},
|
|
2227
|
+
locale: 'en', // stored on the inquiry \u2014 lets staff filter by language
|
|
2228
|
+
sourceMetadata: { page: '/contact' }, // arbitrary provenance (UTM, referrer, etc.)
|
|
2229
|
+
});
|
|
2230
|
+
\`\`\`
|
|
2231
|
+
|
|
2232
|
+
Both shapes go to the same POST endpoint and may be mixed; \`fields\` wins when
|
|
2233
|
+
both provide the same key. Unknown keys (not defined on the form schema) are
|
|
2234
|
+
stripped server-side.
|
|
2235
|
+
|
|
2236
|
+
### Dynamic rendering \u2014 reference implementation
|
|
2237
|
+
|
|
2238
|
+
Render every field type the merchant can pick. Keep this as a \`<DynamicField>\`
|
|
2239
|
+
component so the form is fully driven by \`schema.fields\`.
|
|
2240
|
+
|
|
2241
|
+
**Layout.** Each field carries an optional \`width\` hint:
|
|
2242
|
+
|
|
2243
|
+
| \`field.width\` | Meaning | Grid style (6-column grid) |
|
|
2244
|
+
| ------------- | ------- | -------------------------- |
|
|
2245
|
+
| \`'FULL'\` (default) | Full row | \`grid-column: span 6\` |
|
|
2246
|
+
| \`'HALF'\` | Half row | \`grid-column: span 3\` |
|
|
2247
|
+
| \`'THIRD'\` | One-third row | \`grid-column: span 2\` |
|
|
2248
|
+
|
|
2249
|
+
Stack fields to full width on small screens (< ~640 px).
|
|
2250
|
+
|
|
2251
|
+
**Required fields.** Show a red asterisk on required labels, validate
|
|
2252
|
+
**client-side** before calling \`createInquiry()\`, and show inline error
|
|
2253
|
+
messages. Do NOT rely only on the server returning 400.
|
|
2254
|
+
|
|
2255
|
+
\`\`\`tsx
|
|
2256
|
+
import type { ContactFormPublic, ContactFormPublicField } from 'brainerce';
|
|
2257
|
+
|
|
2258
|
+
type FieldValue = string | string[] | boolean;
|
|
2259
|
+
|
|
2260
|
+
function defaultValueFor(f: ContactFormPublicField): FieldValue {
|
|
2261
|
+
if (f.type === 'CHECKBOX') return false;
|
|
2262
|
+
if (f.type === 'MULTI_SELECT') return [];
|
|
2263
|
+
return f.defaultValue ?? '';
|
|
2264
|
+
}
|
|
2265
|
+
|
|
2266
|
+
function isEmpty(v: FieldValue): boolean {
|
|
2267
|
+
if (typeof v === 'string') return v.trim().length === 0;
|
|
2268
|
+
if (Array.isArray(v)) return v.length === 0;
|
|
2269
|
+
return v === false;
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
// Map width \u2192 CSS grid column span (6-column grid)
|
|
2273
|
+
function widthClass(w?: string): string {
|
|
2274
|
+
switch (w) {
|
|
2275
|
+
case 'HALF': return 'grid-col-half'; // grid-column: span 3; on sm+, full on mobile
|
|
2276
|
+
case 'THIRD': return 'grid-col-third'; // grid-column: span 2; on sm+, full on mobile
|
|
2277
|
+
default: return 'grid-col-full'; // grid-column: span 6
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
function DynamicField({
|
|
2282
|
+
field,
|
|
2283
|
+
value,
|
|
2284
|
+
error,
|
|
2285
|
+
onChange,
|
|
2286
|
+
}: {
|
|
2287
|
+
field: ContactFormPublicField;
|
|
2288
|
+
value: FieldValue;
|
|
2289
|
+
error?: string;
|
|
2290
|
+
onChange: (v: FieldValue) => void;
|
|
2291
|
+
}) {
|
|
2292
|
+
const id = \`contact-\${field.key}\`;
|
|
2293
|
+
const { minLength, maxLength, min, max, pattern } = field.validation ?? {};
|
|
2294
|
+
const strVal = typeof value === 'string' ? value : '';
|
|
2295
|
+
|
|
2296
|
+
// Label \u2014 USE \`field.label\`, NEVER hardcode. Falls back to the key.
|
|
2297
|
+
const label = (
|
|
2298
|
+
<label htmlFor={id} className="mb-1.5 block text-sm font-medium">
|
|
2299
|
+
{field.label}
|
|
2300
|
+
{field.isRequired && <span aria-hidden className="text-red-500"> *</span>}
|
|
2301
|
+
</label>
|
|
2302
|
+
);
|
|
2303
|
+
const help = field.helpText ? <p className="mt-1 text-xs opacity-70">{field.helpText}</p> : null;
|
|
2304
|
+
const errorEl = error ? <p className="mt-1 text-xs text-red-500">{error}</p> : null;
|
|
2305
|
+
|
|
2306
|
+
// Outer div uses widthClass to control grid-column span
|
|
2307
|
+
switch (field.type) {
|
|
2308
|
+
case 'TEXTAREA':
|
|
2309
|
+
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>);
|
|
2310
|
+
case 'EMAIL':
|
|
2311
|
+
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>);
|
|
2312
|
+
case 'PHONE':
|
|
2313
|
+
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>);
|
|
2314
|
+
case 'URL':
|
|
2315
|
+
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>);
|
|
2316
|
+
case 'NUMBER':
|
|
2317
|
+
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>);
|
|
2318
|
+
case 'DATE':
|
|
2319
|
+
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>);
|
|
2320
|
+
case 'SELECT':
|
|
2321
|
+
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>);
|
|
2322
|
+
case 'MULTI_SELECT': {
|
|
2323
|
+
const arr = Array.isArray(value) ? value : [];
|
|
2324
|
+
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>);
|
|
2325
|
+
}
|
|
2326
|
+
case 'CHECKBOX':
|
|
2327
|
+
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>);
|
|
2328
|
+
case 'TEXT':
|
|
2329
|
+
default:
|
|
2330
|
+
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>);
|
|
2331
|
+
}
|
|
2332
|
+
}
|
|
2333
|
+
|
|
2334
|
+
export function ContactPage() {
|
|
2335
|
+
const [schema, setSchema] = useState<ContactFormPublic | null>(null);
|
|
2336
|
+
const [values, setValues] = useState<Record<string, FieldValue>>({});
|
|
2337
|
+
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
2338
|
+
const [honeypot, setHoneypot] = useState('');
|
|
2339
|
+
const [sent, setSent] = useState(false);
|
|
2340
|
+
const [loading, setLoading] = useState(false);
|
|
2341
|
+
|
|
2342
|
+
// Read the current locale from the <html lang> attribute (or your own i18n context).
|
|
2343
|
+
const locale = typeof document !== 'undefined' ? document.documentElement.lang || undefined : undefined;
|
|
2344
|
+
|
|
2345
|
+
useEffect(() => {
|
|
2346
|
+
brainerce.contactForms.get('main', locale).then((form) => {
|
|
2347
|
+
setSchema(form);
|
|
2348
|
+
const initial: Record<string, FieldValue> = {};
|
|
2349
|
+
for (const f of form.fields) initial[f.key] = defaultValueFor(f);
|
|
2350
|
+
setValues(initial);
|
|
2351
|
+
});
|
|
2352
|
+
}, [locale]);
|
|
2353
|
+
|
|
2354
|
+
if (!schema) return null;
|
|
2355
|
+
|
|
2356
|
+
const onSubmit = async (e: React.FormEvent) => {
|
|
2357
|
+
e.preventDefault();
|
|
2358
|
+
if (loading) return;
|
|
2359
|
+
if (honeypot.trim().length > 0) { setSent(true); return; } // bot \u2192 silent success
|
|
2360
|
+
|
|
2361
|
+
// \u2500\u2500 Client-side required-field validation \u2500\u2500
|
|
2362
|
+
const newErrors: Record<string, string> = {};
|
|
2363
|
+
for (const f of schema.fields) {
|
|
2364
|
+
if (f.isRequired && isEmpty(values[f.key] ?? defaultValueFor(f))) {
|
|
2365
|
+
newErrors[f.key] = \`\${f.label} is required\`; // or pull from your i18n system
|
|
2366
|
+
}
|
|
2367
|
+
}
|
|
2368
|
+
if (Object.keys(newErrors).length > 0) {
|
|
2369
|
+
setErrors(newErrors);
|
|
2370
|
+
return; // block submission
|
|
2371
|
+
}
|
|
2372
|
+
setErrors({});
|
|
2373
|
+
|
|
2374
|
+
setLoading(true);
|
|
2375
|
+
try {
|
|
2376
|
+
const payload: Record<string, unknown> = {};
|
|
2377
|
+
for (const f of schema.fields) {
|
|
2378
|
+
const raw = values[f.key];
|
|
2379
|
+
if (isEmpty(raw)) continue;
|
|
2380
|
+
payload[f.key] = typeof raw === 'string' ? raw.trim() : raw;
|
|
2381
|
+
}
|
|
2382
|
+
await brainerce.createInquiry({ formKey: schema.key, fields: payload, locale });
|
|
2383
|
+
setSent(true);
|
|
2384
|
+
} finally {
|
|
2385
|
+
setLoading(false);
|
|
2386
|
+
}
|
|
2387
|
+
};
|
|
2388
|
+
|
|
2389
|
+
if (sent) {
|
|
2390
|
+
// RENDER THE MERCHANT'S success message \u2014 do not hardcode.
|
|
2391
|
+
return <div>{schema.successMessage}</div>;
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
return (
|
|
2395
|
+
<form onSubmit={onSubmit} noValidate>
|
|
2396
|
+
<h1>{schema.name}</h1>
|
|
2397
|
+
{schema.description && <p>{schema.description}</p>}
|
|
2398
|
+
|
|
2399
|
+
{/* Honeypot \u2014 must be visually hidden from humans, not from bots */}
|
|
2400
|
+
<div aria-hidden style={{ position: 'absolute', left: '-10000px', width: 0, height: 0, overflow: 'hidden' }}>
|
|
2401
|
+
<label htmlFor="contact-honeypot">Leave this field empty</label>
|
|
2402
|
+
<input id="contact-honeypot" type="text" tabIndex={-1} autoComplete="off"
|
|
2403
|
+
value={honeypot} onChange={(e) => setHoneypot(e.target.value)} />
|
|
2404
|
+
</div>
|
|
2405
|
+
|
|
2406
|
+
{/* CSS grid \u2014 6 columns on sm+, 1 column on mobile */}
|
|
2407
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(6, 1fr)', gap: '1rem' }}>
|
|
2408
|
+
{schema.fields.map((field) => (
|
|
2409
|
+
<DynamicField key={field.key} field={field}
|
|
2410
|
+
value={values[field.key] ?? defaultValueFor(field)}
|
|
2411
|
+
error={errors[field.key]}
|
|
2412
|
+
onChange={(v) => setValues((p) => ({ ...p, [field.key]: v }))} />
|
|
2413
|
+
))}
|
|
2414
|
+
</div>
|
|
2415
|
+
|
|
2416
|
+
<button type="submit" disabled={loading}>
|
|
2417
|
+
{loading ? '\u2026' : schema.submitButton}
|
|
2418
|
+
</button>
|
|
2419
|
+
</form>
|
|
2420
|
+
);
|
|
2421
|
+
}
|
|
2422
|
+
\`\`\`
|
|
2423
|
+
|
|
2424
|
+
CSS for the grid column helpers (or use the equivalent Tailwind / inline styles):
|
|
2425
|
+
|
|
2426
|
+
\`\`\`css
|
|
2427
|
+
.grid-col-full { grid-column: span 6; }
|
|
2428
|
+
.grid-col-half { grid-column: span 6; }
|
|
2429
|
+
.grid-col-third { grid-column: span 6; }
|
|
2430
|
+
|
|
2431
|
+
@media (min-width: 640px) {
|
|
2432
|
+
.grid-col-half { grid-column: span 3; }
|
|
2433
|
+
.grid-col-third { grid-column: span 2; }
|
|
2434
|
+
}
|
|
2435
|
+
\`\`\`
|
|
2436
|
+
|
|
2437
|
+
### Rules
|
|
2438
|
+
|
|
2439
|
+
- **Rate limit:** 3 submissions / 60s per IP \u2014 show a friendly "try again later" message on HTTP 429.
|
|
2440
|
+
- **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\`.
|
|
2441
|
+
- **Built-in keys:** \`name\`, \`email\`, \`phone\`, \`subject\`, \`message\` always exist on the default form; the legacy \`createInquiry\` shape keeps working forever.
|
|
2442
|
+
- **Max values:** each field value is capped at 10 000 chars server-side. Validate client-side before submitting using \`field.validation.maxLength\`.
|
|
2443
|
+
- **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.
|
|
2444
|
+
- **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\`.
|
|
2445
|
+
- **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\`.
|
|
2446
|
+
- **Enum values:** SELECT and MULTI_SELECT always return \`enumValues\` (non-empty array). Render from \`enumValues\`, never a hardcoded list.
|
|
2447
|
+
- **Visibility:** the server strips fields with \`isVisible=false\`, so \`schema.fields\` only contains things to render.
|
|
2448
|
+
- **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.
|
|
2449
|
+
- **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.
|
|
2450
|
+
- **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.)
|
|
2451
|
+
- **Caching:** the schema GET is safe to cache per \`{storeId, formKey, locale}\`. 60 s feels live without hammering the API.
|
|
2452
|
+
|
|
2453
|
+
### Multiple forms
|
|
2454
|
+
|
|
2455
|
+
If the merchant set up more than one form (e.g. a \`main\` form on \`/contact\`
|
|
2456
|
+
and a \`newsletter\` form embedded in the footer), render each form from its
|
|
2457
|
+
own \`contactForms.get(key, locale)\` call. The \`formKey\` you pass to
|
|
2458
|
+
\`createInquiry\` must match.`;
|
|
1866
2459
|
}
|
|
1867
2460
|
function getSectionByTopic(topic, connectionId, currency) {
|
|
1868
2461
|
const cid = connectionId || "vc_YOUR_CONNECTION_ID";
|
|
@@ -1880,6 +2473,8 @@ function getSectionByTopic(topic, connectionId, currency) {
|
|
|
1880
2473
|
return getCheckoutCustomFieldsSection();
|
|
1881
2474
|
case "payment":
|
|
1882
2475
|
return getPaymentProvidersSection();
|
|
2476
|
+
case "saved-payment-methods":
|
|
2477
|
+
return getSavedPaymentMethodsSection();
|
|
1883
2478
|
case "auth":
|
|
1884
2479
|
return getCustomerAuthSection();
|
|
1885
2480
|
case "order-confirmation":
|
|
@@ -1892,6 +2487,8 @@ function getSectionByTopic(topic, connectionId, currency) {
|
|
|
1892
2487
|
return getRecommendationsSection();
|
|
1893
2488
|
case "product-customization-fields":
|
|
1894
2489
|
return getProductCustomizationFieldsSection();
|
|
2490
|
+
case "modifier-groups":
|
|
2491
|
+
return getModifierGroupsSection();
|
|
1895
2492
|
case "tax":
|
|
1896
2493
|
return getTaxDisplaySection(cur);
|
|
1897
2494
|
case "i18n":
|
|
@@ -1902,6 +2499,8 @@ function getSectionByTopic(topic, connectionId, currency) {
|
|
|
1902
2499
|
return getTypeQuickReference();
|
|
1903
2500
|
case "admin":
|
|
1904
2501
|
return getAdminApiSection();
|
|
2502
|
+
case "inquiries":
|
|
2503
|
+
return getContactInquiriesSection();
|
|
1905
2504
|
case "all":
|
|
1906
2505
|
return [
|
|
1907
2506
|
"# Brainerce SDK \u2014 full topic dump",
|
|
@@ -1970,6 +2569,10 @@ function getSectionByTopic(topic, connectionId, currency) {
|
|
|
1970
2569
|
"",
|
|
1971
2570
|
"---",
|
|
1972
2571
|
"",
|
|
2572
|
+
getModifierGroupsSection(),
|
|
2573
|
+
"",
|
|
2574
|
+
"---",
|
|
2575
|
+
"",
|
|
1973
2576
|
getTaxDisplaySection(cur),
|
|
1974
2577
|
"",
|
|
1975
2578
|
"---",
|
|
@@ -1978,10 +2581,14 @@ function getSectionByTopic(topic, connectionId, currency) {
|
|
|
1978
2581
|
"",
|
|
1979
2582
|
"---",
|
|
1980
2583
|
"",
|
|
1981
|
-
getAdminApiSection()
|
|
2584
|
+
getAdminApiSection(),
|
|
2585
|
+
"",
|
|
2586
|
+
"---",
|
|
2587
|
+
"",
|
|
2588
|
+
getContactInquiriesSection()
|
|
1982
2589
|
].join("\n");
|
|
1983
2590
|
default:
|
|
1984
|
-
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`;
|
|
2591
|
+
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`;
|
|
1985
2592
|
}
|
|
1986
2593
|
}
|
|
1987
2594
|
|
|
@@ -2007,13 +2614,22 @@ var GET_SDK_DOCS_SCHEMA = {
|
|
|
2007
2614
|
"type-reference",
|
|
2008
2615
|
"i18n",
|
|
2009
2616
|
"admin",
|
|
2617
|
+
"inquiries",
|
|
2010
2618
|
"all"
|
|
2011
2619
|
]).describe("The SDK documentation topic to retrieve"),
|
|
2012
|
-
|
|
2620
|
+
salesChannelId: import_zod.z.string().optional().describe("Sales channel ID (starts with vc_). Used to personalize setup code."),
|
|
2621
|
+
/** @deprecated alias of salesChannelId */
|
|
2622
|
+
connectionId: import_zod.z.string().optional().describe("Deprecated alias of salesChannelId \u2014 kept for backwards compat"),
|
|
2013
2623
|
currency: import_zod.z.string().optional().describe("Store currency code (e.g., USD, ILS, EUR). Used in price formatting examples.")
|
|
2014
2624
|
};
|
|
2015
2625
|
async function handleGetSdkDocs(args) {
|
|
2016
|
-
const
|
|
2626
|
+
const id = args.salesChannelId ?? args.connectionId;
|
|
2627
|
+
if (!args.salesChannelId && args.connectionId) {
|
|
2628
|
+
console.warn(
|
|
2629
|
+
"get-sdk-docs: `connectionId` is deprecated \u2014 use `salesChannelId` instead"
|
|
2630
|
+
);
|
|
2631
|
+
}
|
|
2632
|
+
const content = getSectionByTopic(args.topic, id, args.currency);
|
|
2017
2633
|
return {
|
|
2018
2634
|
content: [{ type: "text", text: content }]
|
|
2019
2635
|
};
|
|
@@ -2052,7 +2668,7 @@ interface Product {
|
|
|
2052
2668
|
attributeId: string;
|
|
2053
2669
|
attributeOptionId: string;
|
|
2054
2670
|
platform: string;
|
|
2055
|
-
attribute: { id: string; name: string; displayType?: 'DEFAULT' | 'COLOR_SWATCH' | 'IMAGE_SWATCH' } | null;
|
|
2671
|
+
attribute: { id: string; name: string; displayType?: 'DEFAULT' | 'COLOR_SWATCH' | 'IMAGE_SWATCH' | 'MIXED_SWATCH' } | null;
|
|
2056
2672
|
attributeOption: { id: string; name: string; value?: string | null; swatchColor?: string | null; swatchColor2?: string | null; swatchImageUrl?: string | null } | null;
|
|
2057
2673
|
}>;
|
|
2058
2674
|
createdAt: string;
|
|
@@ -2061,6 +2677,8 @@ interface Product {
|
|
|
2061
2677
|
|
|
2062
2678
|
// Use getProductSwatches(product) to get grouped swatch data for storefront rendering.
|
|
2063
2679
|
// Returns Array<{ attributeName, displayType, options: Array<{ name, swatchColor?, swatchColor2?, swatchImageUrl? }> }>
|
|
2680
|
+
// displayType rendering: COLOR_SWATCH \u2192 color circle (swatchColor/swatchColor2); IMAGE_SWATCH \u2192 image thumbnail (swatchImageUrl);
|
|
2681
|
+
// MIXED_SWATCH \u2192 per-option: render swatchImageUrl if set, else swatchColor if set, else text only.
|
|
2064
2682
|
|
|
2065
2683
|
interface ProductImage {
|
|
2066
2684
|
url: string;
|
|
@@ -2141,6 +2759,10 @@ interface ProductQueryParams {
|
|
|
2141
2759
|
tags?: string | string[];
|
|
2142
2760
|
minPrice?: number;
|
|
2143
2761
|
maxPrice?: number;
|
|
2762
|
+
// Filter by custom-field (metafield) values. Keys = definition.key,
|
|
2763
|
+
// values = accepted values. Only definitions with filterable=true and
|
|
2764
|
+
// type SELECT/MULTI_SELECT/BOOLEAN are honored. AND across keys, OR within.
|
|
2765
|
+
metafields?: Record<string, string | string[]>;
|
|
2144
2766
|
sortBy?: 'name' | 'price' | 'createdAt';
|
|
2145
2767
|
sortOrder?: 'asc' | 'desc';
|
|
2146
2768
|
}
|
|
@@ -2558,6 +3180,36 @@ interface PaymentStatus {
|
|
|
2558
3180
|
error?: string;
|
|
2559
3181
|
}
|
|
2560
3182
|
|
|
3183
|
+
// ---- Saved Payment Methods (vaulted cards) ----
|
|
3184
|
+
// Display-only summary of a customer's vaulted payment method. The
|
|
3185
|
+
// underlying provider token is encrypted at rest in the platform DB
|
|
3186
|
+
// and NEVER returned through the SDK.
|
|
3187
|
+
//
|
|
3188
|
+
// To opt into vaulting at checkout, pass saveCard: true to
|
|
3189
|
+
// createPaymentIntent (only honored for logged-in customers).
|
|
3190
|
+
//
|
|
3191
|
+
// To list / remove saved methods, see:
|
|
3192
|
+
// client.listSavedPaymentMethods(storeId, customerId)
|
|
3193
|
+
// client.removeSavedPaymentMethod(storeId, customerId, methodId)
|
|
3194
|
+
|
|
3195
|
+
interface SavedPaymentMethodSummary {
|
|
3196
|
+
id: string;
|
|
3197
|
+
customerId: string;
|
|
3198
|
+
appInstallationId: string;
|
|
3199
|
+
paymentMethod: string; // 'credit_card' | 'paypal' | 'bank_account'
|
|
3200
|
+
brand: string | null;
|
|
3201
|
+
last4: string | null;
|
|
3202
|
+
expMonth: number | null;
|
|
3203
|
+
expYear: number | null;
|
|
3204
|
+
isDefault: boolean;
|
|
3205
|
+
status: string; // 'active' | 'expired' | 'invalid'
|
|
3206
|
+
failureReason: string | null;
|
|
3207
|
+
lastUsedAt: string | null;
|
|
3208
|
+
expiresAt: string | null;
|
|
3209
|
+
createdAt: string;
|
|
3210
|
+
updatedAt: string;
|
|
3211
|
+
}
|
|
3212
|
+
|
|
2561
3213
|
interface WaitForOrderResult {
|
|
2562
3214
|
success: boolean;
|
|
2563
3215
|
status: PaymentStatus; // Access: result.status.orderNumber
|
|
@@ -2657,16 +3309,267 @@ interface CartUpgradeSuggestion {
|
|
|
2657
3309
|
priceDelta: string;
|
|
2658
3310
|
deltaPercent: number;
|
|
2659
3311
|
}
|
|
2660
|
-
interface
|
|
3312
|
+
interface CartBundleOfferOfferedProduct {
|
|
2661
3313
|
id: string;
|
|
2662
|
-
|
|
3314
|
+
name: string;
|
|
3315
|
+
slug: string | null;
|
|
3316
|
+
basePrice: string;
|
|
3317
|
+
salePrice: string | null;
|
|
3318
|
+
images: Array<{ url: string }>;
|
|
3319
|
+
type: string;
|
|
2663
3320
|
originalPrice: string;
|
|
2664
3321
|
discountedPrice: string;
|
|
3322
|
+
}
|
|
3323
|
+
interface CartBundleOffer {
|
|
3324
|
+
id: string;
|
|
3325
|
+
name: string;
|
|
3326
|
+
description: string | null;
|
|
3327
|
+
// productIds[0] = trigger product (must be in cart for the bundle to surface);
|
|
3328
|
+
// productIds[1..] = offered together at the bundle discount.
|
|
3329
|
+
triggerProductId: string;
|
|
3330
|
+
productIds: string[];
|
|
3331
|
+
// offeredProducts = productIds[1..] minus those already in cart, each with
|
|
3332
|
+
// its own original/discounted price applied.
|
|
3333
|
+
offeredProducts: CartBundleOfferOfferedProduct[];
|
|
2665
3334
|
discountType: 'PERCENTAGE' | 'FIXED_AMOUNT';
|
|
2666
|
-
discountValue:
|
|
2667
|
-
|
|
2668
|
-
|
|
3335
|
+
discountValue: string;
|
|
3336
|
+
totalOriginalPrice: string;
|
|
3337
|
+
totalDiscountedPrice: string;
|
|
2669
3338
|
}`;
|
|
3339
|
+
var INQUIRIES_TYPES = `// ---- Contact Inquiries & Forms ----
|
|
3340
|
+
|
|
3341
|
+
// Two shapes \u2014 legacy and flexible. The two may be mixed; \`fields\` wins on key collision.
|
|
3342
|
+
interface CreateInquiryInput {
|
|
3343
|
+
// Legacy shape (still supported forever, backed by default "main" form)
|
|
3344
|
+
name?: string; // max 120 chars (if provided)
|
|
3345
|
+
email?: string; // must be valid email if provided
|
|
3346
|
+
subject?: string; // max 200 chars
|
|
3347
|
+
message?: string; // max 10000 chars
|
|
3348
|
+
phone?: string;
|
|
3349
|
+
|
|
3350
|
+
// Flexible shape
|
|
3351
|
+
formKey?: string; // defaults to "main"; merchant-defined keys e.g. "newsletter"
|
|
3352
|
+
fields?: Record<string, unknown>; // bag of values keyed by field key (built-in or custom)
|
|
3353
|
+
locale?: string; // e.g. "en", "he" \u2014 stored on the inquiry
|
|
3354
|
+
sourceMetadata?: Record<string, unknown>; // arbitrary context (e.g. { page: '/contact', campaign: 'fall-2026' })
|
|
3355
|
+
|
|
3356
|
+
// Shared
|
|
3357
|
+
customerId?: string; // link to logged-in customer
|
|
3358
|
+
metadata?: Record<string, unknown>; // deprecated alias of sourceMetadata
|
|
3359
|
+
}
|
|
3360
|
+
|
|
3361
|
+
interface CreateInquiryResponse {
|
|
3362
|
+
id: string;
|
|
3363
|
+
status: 'NEW';
|
|
3364
|
+
createdAt: string; // ISO datetime
|
|
3365
|
+
}
|
|
3366
|
+
|
|
3367
|
+
// ---- Form schema (for dynamic rendering) ----
|
|
3368
|
+
|
|
3369
|
+
type ContactFormFieldType =
|
|
3370
|
+
| 'TEXT' | 'TEXTAREA' | 'EMAIL' | 'PHONE' | 'NUMBER'
|
|
3371
|
+
| 'SELECT' | 'MULTI_SELECT' | 'CHECKBOX' | 'URL' | 'DATE';
|
|
3372
|
+
|
|
3373
|
+
interface ContactFormFieldValidation {
|
|
3374
|
+
minLength?: number;
|
|
3375
|
+
maxLength?: number;
|
|
3376
|
+
min?: number;
|
|
3377
|
+
max?: number;
|
|
3378
|
+
pattern?: string; // regex string
|
|
3379
|
+
patternMessage?: string;
|
|
3380
|
+
}
|
|
3381
|
+
|
|
3382
|
+
interface ContactFormPublicField {
|
|
3383
|
+
key: string; // stable identifier, e.g. "email", "company"
|
|
3384
|
+
type: ContactFormFieldType;
|
|
3385
|
+
label: string; // already localized for the requested locale
|
|
3386
|
+
placeholder?: string; // already localized
|
|
3387
|
+
helpText?: string; // already localized
|
|
3388
|
+
isRequired: boolean;
|
|
3389
|
+
enumValues?: { value: string; label: string }[]; // present (non-empty) for SELECT / MULTI_SELECT
|
|
3390
|
+
validation?: ContactFormFieldValidation;
|
|
3391
|
+
defaultValue?: string;
|
|
3392
|
+
width?: 'FULL' | 'HALF' | 'THIRD'; // layout hint \u2014 FULL = full row, HALF = half row, THIRD = one-third row
|
|
3393
|
+
}
|
|
3394
|
+
|
|
3395
|
+
interface ContactFormPublic {
|
|
3396
|
+
id: string;
|
|
3397
|
+
key: string; // e.g. "main", "newsletter"
|
|
3398
|
+
name: string; // already localized \u2014 use as form heading
|
|
3399
|
+
description?: string; // already localized \u2014 use as subtitle
|
|
3400
|
+
submitButton: string; // already localized \u2014 use as submit label
|
|
3401
|
+
successMessage: string; // already localized \u2014 render after submit succeeds
|
|
3402
|
+
fields: ContactFormPublicField[]; // in display order; hidden fields already filtered out
|
|
3403
|
+
}
|
|
3404
|
+
|
|
3405
|
+
interface ContactFormSummary {
|
|
3406
|
+
key: string;
|
|
3407
|
+
name: string;
|
|
3408
|
+
isDefault: boolean;
|
|
3409
|
+
}
|
|
3410
|
+
|
|
3411
|
+
// Field-type \u2192 HTML mapping for dynamic rendering
|
|
3412
|
+
// ------------------------------------------------------------------
|
|
3413
|
+
// TEXT \u2192 <input type="text" ... pattern?={validation.pattern}>
|
|
3414
|
+
// TEXTAREA \u2192 <textarea rows={6} ...>
|
|
3415
|
+
// EMAIL \u2192 <input type="email" autoComplete="email" ...>
|
|
3416
|
+
// PHONE \u2192 <input type="tel" autoComplete="tel" ...>
|
|
3417
|
+
// NUMBER \u2192 <input type="number" min={validation.min} max={validation.max}>
|
|
3418
|
+
// URL \u2192 <input type="url" ...>
|
|
3419
|
+
// DATE \u2192 <input type="date" ...> (value is ISO yyyy-MM-dd)
|
|
3420
|
+
// SELECT \u2192 <select>{enumValues.map(...)}</select> \u2014 always rendered from enumValues
|
|
3421
|
+
// MULTI_SELECT \u2192 multiple <input type="checkbox">, value is string[]
|
|
3422
|
+
// CHECKBOX \u2192 single <input type="checkbox">, value is boolean
|
|
3423
|
+
// ------------------------------------------------------------------
|
|
3424
|
+
//
|
|
3425
|
+
// Layout: render fields inside a CSS grid container.
|
|
3426
|
+
// width='FULL' (default) \u2192 span entire row
|
|
3427
|
+
// width='HALF' \u2192 span half the row (two HALF fields sit side-by-side)
|
|
3428
|
+
// width='THIRD' \u2192 span one-third of the row (three THIRD fields sit side-by-side)
|
|
3429
|
+
// Use a 6-column grid for clean divisibility:
|
|
3430
|
+
// FULL \u2192 col-span-6 | HALF \u2192 col-span-3 | THIRD \u2192 col-span-2
|
|
3431
|
+
// Stack to full width on small screens (< sm breakpoint).
|
|
3432
|
+
//
|
|
3433
|
+
// Required fields: show a red asterisk (*) next to the label, validate
|
|
3434
|
+
// client-side before submission, and display inline error messages for
|
|
3435
|
+
// any empty required field. Do NOT rely only on the server returning 400.
|
|
3436
|
+
// ------------------------------------------------------------------
|
|
3437
|
+
|
|
3438
|
+
// SDK methods
|
|
3439
|
+
// await brainerce.createInquiry(input) \u2192 POST /stores/{storeId}/inquiries
|
|
3440
|
+
// await brainerce.contactForms.list() \u2192 GET /stores/{storeId}/contact-forms
|
|
3441
|
+
// await brainerce.contactForms.get(key?, locale?) \u2192 GET /stores/{storeId}/contact-forms/{key}?locale={locale}
|
|
3442
|
+
//
|
|
3443
|
+
// Rules
|
|
3444
|
+
// - Rate limit: 3 submissions / 60s per IP \u2014 handle 429 responses gracefully
|
|
3445
|
+
// - Honeypot: always render an invisible field named \`honeypot\` and never send it
|
|
3446
|
+
// - Always pass \`locale\` \u2014 inbox filters inquiries by language, and schema labels come back translated
|
|
3447
|
+
// - Render \`schema.name\` / \`description\` / \`submitButton\` / \`successMessage\` directly \u2014 do NOT hardcode copy
|
|
3448
|
+
// - Unknown field keys are stripped server-side \u2014 safe to send extras during dev`;
|
|
3449
|
+
var MODIFIER_GROUPS_TYPES = `// ---- Modifier Groups (Restaurant / Build-Your-Own) ----
|
|
3450
|
+
// Modifier groups are merchant-defined option blocks attached to a product
|
|
3451
|
+
// (toppings, sauce, bread type, \u2026). They differ from product.customizationFields:
|
|
3452
|
+
// modifier groups are STRUCTURED priced choices validated server-side, while
|
|
3453
|
+
// customizationFields are arbitrary buyer input (text/photo/color).
|
|
3454
|
+
//
|
|
3455
|
+
// Money fields are decimal STRINGS on the wire \u2014 "5.00", "-2.00" (downsell).
|
|
3456
|
+
// Never JSON Number. Use parseFloat() only at display time. Do not compute
|
|
3457
|
+
// the line total client-side; the server runs free-allocation and returns
|
|
3458
|
+
// cart.items[i].unitPrice + .modifiers[] + .modifiersTotal.
|
|
3459
|
+
|
|
3460
|
+
export type ModifierSelectionType = 'SINGLE' | 'MULTIPLE';
|
|
3461
|
+
|
|
3462
|
+
export type FreeAllocationPolicy = 'EXPENSIVE_FREE' | 'CHEAPEST_FREE' | 'SELECTION_ORDER';
|
|
3463
|
+
|
|
3464
|
+
export interface Modifier {
|
|
3465
|
+
id: string;
|
|
3466
|
+
name: string;
|
|
3467
|
+
description?: string;
|
|
3468
|
+
/** Decimal string. Negative values = downsell modifiers ("-2.00"). */
|
|
3469
|
+
priceDelta: string;
|
|
3470
|
+
sku?: string;
|
|
3471
|
+
image?: { url: string; thumbnailUrl?: string; alt?: string };
|
|
3472
|
+
position: number;
|
|
3473
|
+
/** Pre-checked on first render. */
|
|
3474
|
+
isDefault: boolean;
|
|
3475
|
+
/** false = sold out \u2014 disable in UI with a "Sold out" badge. */
|
|
3476
|
+
available: boolean;
|
|
3477
|
+
/** When true, never applied as a free selection \u2014 always charges priceDelta even when freeQuantity > 0 on the group. */
|
|
3478
|
+
excludeFromFree?: boolean;
|
|
3479
|
+
/** Nested combo: opens a sub-flow; depth \u2264 3 enforced server-side. */
|
|
3480
|
+
referencedProductId?: string;
|
|
3481
|
+
translations?: Record<string, { name?: string; description?: string }>;
|
|
3482
|
+
}
|
|
3483
|
+
|
|
3484
|
+
export interface ModifierGroup {
|
|
3485
|
+
id: string;
|
|
3486
|
+
/**
|
|
3487
|
+
* Set when fetched in a product context (the ProductModifierGroup row id).
|
|
3488
|
+
* Use this to update or detach the attachment without reattaching the group.
|
|
3489
|
+
*/
|
|
3490
|
+
attachmentId?: string;
|
|
3491
|
+
/** Customer-facing canonical name. */
|
|
3492
|
+
name: string;
|
|
3493
|
+
/**
|
|
3494
|
+
* Admin-only disambiguator \u2014 NEVER present in storefront responses.
|
|
3495
|
+
* If your client code reads this, you're hitting an admin endpoint by mistake.
|
|
3496
|
+
*/
|
|
3497
|
+
internalName?: string;
|
|
3498
|
+
description?: string;
|
|
3499
|
+
selectionType: ModifierSelectionType;
|
|
3500
|
+
/** Effective minimum after any per-attach / per-variant overrides. */
|
|
3501
|
+
min: number;
|
|
3502
|
+
/** Effective maximum; null = unlimited. **0 = group hidden for this variant** (PRD \xA77.2.2). */
|
|
3503
|
+
max?: number | null;
|
|
3504
|
+
freeQuantity: number;
|
|
3505
|
+
required: boolean;
|
|
3506
|
+
freeAllocationPolicy: FreeAllocationPolicy;
|
|
3507
|
+
modifiers: Modifier[];
|
|
3508
|
+
/** Effective default selections (after attachment-level overrides). */
|
|
3509
|
+
defaultModifierIds: string[];
|
|
3510
|
+
translations?: Record<string, { name?: string; description?: string }>;
|
|
3511
|
+
}
|
|
3512
|
+
|
|
3513
|
+
/** Customer-side selection payload \u2014 modifierIds in click-order. */
|
|
3514
|
+
export interface ModifierSelection {
|
|
3515
|
+
modifierGroupId: string;
|
|
3516
|
+
modifierIds: string[];
|
|
3517
|
+
}
|
|
3518
|
+
|
|
3519
|
+
/** Per-line modifier breakdown surfaced on cart.items[i] / order.items[i]. */
|
|
3520
|
+
export interface CartItemModifierLine {
|
|
3521
|
+
modifierId: string;
|
|
3522
|
+
/** Snapshot of the modifier's name at the time the line was added. */
|
|
3523
|
+
name: string;
|
|
3524
|
+
/** Decimal string snapshot of the priceDelta at the time the line was added. */
|
|
3525
|
+
priceDelta: string;
|
|
3526
|
+
/** True if this modifier consumed one of the group's free slots. */
|
|
3527
|
+
freeApplied: boolean;
|
|
3528
|
+
}
|
|
3529
|
+
|
|
3530
|
+
/**
|
|
3531
|
+
* Stable error codes returned in the structured 400 envelope when a cart
|
|
3532
|
+
* payload fails server-side validation. The SDK exposes the envelope on
|
|
3533
|
+
* BrainerceError.details \u2014 switch on details.code === 'MODIFIER_VALIDATION_FAILED'
|
|
3534
|
+
* first, then iterate details.errors[].
|
|
3535
|
+
*/
|
|
3536
|
+
export type ModifierValidationCode =
|
|
3537
|
+
| 'REQUIRED_GROUP_MISSING'
|
|
3538
|
+
| 'MIN_SELECTIONS_NOT_MET'
|
|
3539
|
+
| 'MAX_SELECTIONS_EXCEEDED'
|
|
3540
|
+
| 'SINGLE_GROUP_MULTIPLE_PICKS'
|
|
3541
|
+
| 'UNKNOWN_MODIFIER'
|
|
3542
|
+
| 'UNKNOWN_GROUP'
|
|
3543
|
+
| 'MODIFIER_DISABLED_FOR_VARIANT'
|
|
3544
|
+
| 'MODIFIER_NOT_AVAILABLE'
|
|
3545
|
+
| 'NESTED_DEPTH_EXCEEDED'
|
|
3546
|
+
| 'NESTED_REQUIRES_PRODUCT_REF'
|
|
3547
|
+
| 'INVALID_PRICE_DELTA'
|
|
3548
|
+
| 'MODIFIER_PRICE_FLOOR_VIOLATED';
|
|
3549
|
+
|
|
3550
|
+
export interface ModifierValidationError {
|
|
3551
|
+
code: ModifierValidationCode;
|
|
3552
|
+
message: string;
|
|
3553
|
+
modifierGroupId?: string;
|
|
3554
|
+
modifierId?: string;
|
|
3555
|
+
}
|
|
3556
|
+
|
|
3557
|
+
// Cart DTOs gain optional selections + nestedByModifierId \u2014 see CART_TYPES above.
|
|
3558
|
+
// Add to cart with selections:
|
|
3559
|
+
//
|
|
3560
|
+
// await client.smartAddToCart({
|
|
3561
|
+
// productId,
|
|
3562
|
+
// variantId,
|
|
3563
|
+
// quantity: 1,
|
|
3564
|
+
// selections: [
|
|
3565
|
+
// { modifierGroupId: 'mg_bread', modifierIds: ['m_thick'] },
|
|
3566
|
+
// { modifierGroupId: 'mg_toppings', modifierIds: ['m_olive', 'm_bacon'] },
|
|
3567
|
+
// ],
|
|
3568
|
+
// });
|
|
3569
|
+
//
|
|
3570
|
+
// The cart line then carries:
|
|
3571
|
+
// cart.items[i].modifiers \u2014 CartItemModifierLine[]
|
|
3572
|
+
// cart.items[i].modifiersTotal \u2014 decimal string of the paid (non-free) deltas`;
|
|
2670
3573
|
var TYPES_BY_DOMAIN = {
|
|
2671
3574
|
products: PRODUCTS_TYPES,
|
|
2672
3575
|
cart: CART_TYPES,
|
|
@@ -2674,7 +3577,9 @@ var TYPES_BY_DOMAIN = {
|
|
|
2674
3577
|
orders: ORDERS_TYPES,
|
|
2675
3578
|
customers: CUSTOMERS_TYPES,
|
|
2676
3579
|
payments: PAYMENTS_TYPES,
|
|
2677
|
-
helpers: HELPERS_TYPES
|
|
3580
|
+
helpers: HELPERS_TYPES,
|
|
3581
|
+
inquiries: INQUIRIES_TYPES,
|
|
3582
|
+
"modifier-groups": MODIFIER_GROUPS_TYPES
|
|
2678
3583
|
};
|
|
2679
3584
|
function getTypesByDomain(domain) {
|
|
2680
3585
|
if (domain === "all") {
|
|
@@ -2695,7 +3600,17 @@ var AVAILABLE_DOMAINS = Object.keys(TYPES_BY_DOMAIN);
|
|
|
2695
3600
|
var GET_TYPE_DEFINITIONS_NAME = "get-type-definitions";
|
|
2696
3601
|
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.";
|
|
2697
3602
|
var GET_TYPE_DEFINITIONS_SCHEMA = {
|
|
2698
|
-
domain: import_zod2.z.enum([
|
|
3603
|
+
domain: import_zod2.z.enum([
|
|
3604
|
+
"products",
|
|
3605
|
+
"cart",
|
|
3606
|
+
"checkout",
|
|
3607
|
+
"orders",
|
|
3608
|
+
"customers",
|
|
3609
|
+
"payments",
|
|
3610
|
+
"helpers",
|
|
3611
|
+
"inquiries",
|
|
3612
|
+
"all"
|
|
3613
|
+
]).describe(
|
|
2699
3614
|
'The domain of types to retrieve. Use "helpers" for helper function signatures and common types like StoreInfo, PaginatedResponse.'
|
|
2700
3615
|
)
|
|
2701
3616
|
};
|
|
@@ -2730,7 +3645,12 @@ var GET_CODE_EXAMPLE_SCHEMA = {
|
|
|
2730
3645
|
"reservation-countdown",
|
|
2731
3646
|
"search-autocomplete-debounce",
|
|
2732
3647
|
"i18n-set-locale",
|
|
2733
|
-
"order-history-full"
|
|
3648
|
+
"order-history-full",
|
|
3649
|
+
"modifier-groups-render",
|
|
3650
|
+
"cart-add-with-selections",
|
|
3651
|
+
"modifier-validation-error-handling",
|
|
3652
|
+
"checkout-price-drift",
|
|
3653
|
+
"cart-item-modifier-display"
|
|
2734
3654
|
]).describe("The SDK operation to get a snippet for.")
|
|
2735
3655
|
};
|
|
2736
3656
|
var SNIPPETS = {
|
|
@@ -2738,7 +3658,10 @@ var SNIPPETS = {
|
|
|
2738
3658
|
import { BrainerceClient } from 'brainerce';
|
|
2739
3659
|
|
|
2740
3660
|
export const client = new BrainerceClient({
|
|
2741
|
-
|
|
3661
|
+
// Read either env var name (the new one is preferred; the old one is a soft
|
|
3662
|
+
// alias kept for backwards compatibility \u2014 both are accepted by the SDK).
|
|
3663
|
+
salesChannelId:
|
|
3664
|
+
process.env.BRAINERCE_SALES_CHANNEL_ID! ?? process.env.BRAINERCE_CONNECTION_ID!, // vc_*
|
|
2742
3665
|
});
|
|
2743
3666
|
|
|
2744
3667
|
// If the store has i18n enabled, set the locale at app start based on
|
|
@@ -3158,7 +4081,418 @@ for (const order of orders) {
|
|
|
3158
4081
|
|
|
3159
4082
|
// All sections above are conditional. Absent data = section not rendered \u2014
|
|
3160
4083
|
// never show empty placeholders. The create-brainerce-store template's
|
|
3161
|
-
// src/components/account/order-history.tsx is the reference implementation
|
|
4084
|
+
// src/components/account/order-history.tsx is the reference implementation.`,
|
|
4085
|
+
"modifier-groups-render": `// Render modifier groups on the product detail page (toppings / sauce /
|
|
4086
|
+
// build-your-own). The data lives on product.modifierGroups \u2014 only present
|
|
4087
|
+
// for restaurant / customizable products.
|
|
4088
|
+
import { useState } from 'react';
|
|
4089
|
+
import type { ModifierGroup, Product } from 'brainerce';
|
|
4090
|
+
|
|
4091
|
+
function ModifierGroups({ product }: { product: Product }) {
|
|
4092
|
+
const groups: ModifierGroup[] = product.modifierGroups ?? [];
|
|
4093
|
+
if (groups.length === 0) return null; // not a customizable product
|
|
4094
|
+
|
|
4095
|
+
// Local state: selected modifier IDs per group, in click-order.
|
|
4096
|
+
const [selections, setSelections] = useState<Record<string, string[]>>(() =>
|
|
4097
|
+
buildInitialSelections(groups)
|
|
4098
|
+
);
|
|
4099
|
+
|
|
4100
|
+
return (
|
|
4101
|
+
<>
|
|
4102
|
+
{groups.map((group) => {
|
|
4103
|
+
// PRD \xA77.2.2: max === 0 means "hidden for this variant" \u2014 skip entirely.
|
|
4104
|
+
if (group.max === 0) return null;
|
|
4105
|
+
|
|
4106
|
+
const isSingle = group.selectionType === 'SINGLE';
|
|
4107
|
+
const inputType = isSingle ? 'radio' : 'checkbox';
|
|
4108
|
+
const sorted = [...group.modifiers].sort((a, b) => a.position - b.position);
|
|
4109
|
+
const picks = selections[group.id] ?? [];
|
|
4110
|
+
const usedFree = Math.min(picks.length, group.freeQuantity);
|
|
4111
|
+
|
|
4112
|
+
const toggle = (modifierId: string, checked: boolean) => {
|
|
4113
|
+
setSelections((prev) => {
|
|
4114
|
+
const cur = prev[group.id] ?? [];
|
|
4115
|
+
if (isSingle) return { ...prev, [group.id]: checked ? [modifierId] : [] };
|
|
4116
|
+
if (checked) {
|
|
4117
|
+
if (group.max != null && cur.length >= group.max) return prev; // at cap
|
|
4118
|
+
return { ...prev, [group.id]: [...cur, modifierId] };
|
|
4119
|
+
}
|
|
4120
|
+
return { ...prev, [group.id]: cur.filter((id) => id !== modifierId) };
|
|
4121
|
+
});
|
|
4122
|
+
};
|
|
4123
|
+
|
|
4124
|
+
return (
|
|
4125
|
+
<fieldset key={group.id}>
|
|
4126
|
+
<legend>
|
|
4127
|
+
{group.name}{group.required && ' *'}
|
|
4128
|
+
</legend>
|
|
4129
|
+
{group.freeQuantity > 0 && (
|
|
4130
|
+
<p>{usedFree} of {group.freeQuantity} free</p>
|
|
4131
|
+
)}
|
|
4132
|
+
{sorted.map((m) => (
|
|
4133
|
+
<label key={m.id}>
|
|
4134
|
+
<input
|
|
4135
|
+
type={inputType}
|
|
4136
|
+
name={\`mg-\${group.id}\`}
|
|
4137
|
+
value={m.id}
|
|
4138
|
+
checked={picks.includes(m.id)}
|
|
4139
|
+
disabled={!m.available}
|
|
4140
|
+
onChange={(e) => toggle(m.id, e.target.checked)}
|
|
4141
|
+
/>
|
|
4142
|
+
{m.name}
|
|
4143
|
+
{parseFloat(m.priceDelta) !== 0 && (
|
|
4144
|
+
<span> {parseFloat(m.priceDelta) > 0 ? '+' : ''}{m.priceDelta}</span>
|
|
4145
|
+
)}
|
|
4146
|
+
{!m.available && <span> (Sold out)</span>}
|
|
4147
|
+
</label>
|
|
4148
|
+
))}
|
|
4149
|
+
</fieldset>
|
|
4150
|
+
);
|
|
4151
|
+
})}
|
|
4152
|
+
</>
|
|
4153
|
+
);
|
|
4154
|
+
}
|
|
4155
|
+
|
|
4156
|
+
// Initial state: prefer per-attach defaultModifierIds; fall back to
|
|
4157
|
+
// modifier.isDefault flags. Filter sold-out modifiers.
|
|
4158
|
+
function buildInitialSelections(groups: ModifierGroup[]): Record<string, string[]> {
|
|
4159
|
+
const out: Record<string, string[]> = {};
|
|
4160
|
+
for (const group of groups) {
|
|
4161
|
+
if (group.max === 0) continue;
|
|
4162
|
+
const fromAttach = group.defaultModifierIds ?? [];
|
|
4163
|
+
const fromIsDefault = group.modifiers
|
|
4164
|
+
.filter((m) => m.isDefault && m.available)
|
|
4165
|
+
.map((m) => m.id);
|
|
4166
|
+
const merged =
|
|
4167
|
+
fromAttach.length > 0
|
|
4168
|
+
? fromAttach.filter((id) => group.modifiers.some((m) => m.id === id && m.available))
|
|
4169
|
+
: fromIsDefault;
|
|
4170
|
+
const capped = group.selectionType === 'SINGLE' ? merged.slice(0, 1) : merged;
|
|
4171
|
+
if (capped.length > 0) out[group.id] = capped;
|
|
4172
|
+
}
|
|
4173
|
+
return out;
|
|
4174
|
+
}
|
|
4175
|
+
|
|
4176
|
+
// PRICE-DELTA RULES:
|
|
4177
|
+
// - "5.00" \u2192 +$5 added to unit price
|
|
4178
|
+
// - "-2.00" \u2192 downsell: subtracts $2; never consumes a free slot
|
|
4179
|
+
// - "0.00" \u2192 free option; render no price label
|
|
4180
|
+
// Server enforces unitPrice >= 0; rejects negative deltas combined with
|
|
4181
|
+
// referencedProductId. Do NOT compute the line total client-side \u2014 the
|
|
4182
|
+
// server runs free-allocation and returns cart.items[i].unitPrice.
|
|
4183
|
+
|
|
4184
|
+
`,
|
|
4185
|
+
"cart-add-with-selections": `// Pass modifier-group selections on add-to-cart. The server validates
|
|
4186
|
+
// against the effective rules and computes the final unitPrice (base +
|
|
4187
|
+
// paid modifiers, after the free-allocation policy runs).
|
|
4188
|
+
import { client } from './brainerce';
|
|
4189
|
+
import type { ModifierSelection } from 'brainerce';
|
|
4190
|
+
|
|
4191
|
+
async function addPizzaToCart(
|
|
4192
|
+
productId: string,
|
|
4193
|
+
variantId: string,
|
|
4194
|
+
selectionsByGroup: Record<string, string[]>
|
|
4195
|
+
) {
|
|
4196
|
+
// Adapter: shape used in component state \u2192 wire format the SDK accepts.
|
|
4197
|
+
const selections: ModifierSelection[] = Object.entries(selectionsByGroup)
|
|
4198
|
+
.filter(([, modifierIds]) => modifierIds.length > 0)
|
|
4199
|
+
.map(([modifierGroupId, modifierIds]) => ({ modifierGroupId, modifierIds }));
|
|
4200
|
+
|
|
4201
|
+
// modifierIds is in CLICK-ORDER \u2014 used by the SELECTION_ORDER free-allocation
|
|
4202
|
+
// policy. Don't sort it; preserve the order the customer clicked.
|
|
4203
|
+
|
|
4204
|
+
const cart = await client.smartAddToCart({
|
|
4205
|
+
productId,
|
|
4206
|
+
variantId,
|
|
4207
|
+
quantity: 1,
|
|
4208
|
+
selections,
|
|
4209
|
+
});
|
|
4210
|
+
|
|
4211
|
+
// Read the snapshot back off the new cart line.
|
|
4212
|
+
const line = cart.items[cart.items.length - 1];
|
|
4213
|
+
// line.modifiers \u2192 CartItemModifierLine[] with { modifierId, name, priceDelta, freeApplied }
|
|
4214
|
+
// line.modifiersTotal \u2192 decimal string of paid (non-free) deltas
|
|
4215
|
+
// line.unitPrice \u2192 final unit price including all paid modifiers
|
|
4216
|
+
console.log('Free applied:', line.modifiers?.filter((m) => m.freeApplied).map((m) => m.name));
|
|
4217
|
+
}
|
|
4218
|
+
|
|
4219
|
+
// EDITING SELECTIONS LATER (PRD \xA77.2.3 \u2014 idempotent replacement):
|
|
4220
|
+
// PATCH the cart item with a fresh selections array. The server deletes ALL
|
|
4221
|
+
// existing CartItemModifier rows and recreates them in the same transaction.
|
|
4222
|
+
// To leave selections untouched (e.g., quantity-only update), omit them.
|
|
4223
|
+
async function changeToppings(cartId: string, itemId: string, newToppingIds: string[]) {
|
|
4224
|
+
await client.updateCartItem(cartId, itemId, {
|
|
4225
|
+
quantity: 1,
|
|
4226
|
+
selections: [{ modifierGroupId: 'mg_toppings', modifierIds: newToppingIds }],
|
|
4227
|
+
});
|
|
4228
|
+
}
|
|
4229
|
+
|
|
4230
|
+
// NESTED COMBOS (depth \u2264 3): when a picked modifier carries
|
|
4231
|
+
// referencedProductId, fetch that product's own modifierGroups, collect the
|
|
4232
|
+
// nested selections, and pass them keyed by the PARENT modifier id.
|
|
4233
|
+
async function addCombo(productId: string, mainBurger: string) {
|
|
4234
|
+
await client.smartAddToCart({
|
|
4235
|
+
productId,
|
|
4236
|
+
quantity: 1,
|
|
4237
|
+
selections: [
|
|
4238
|
+
{ modifierGroupId: 'mg_main', modifierIds: [mainBurger] }, // the burger
|
|
4239
|
+
{ modifierGroupId: 'mg_drink', modifierIds: ['m_cola'] },
|
|
4240
|
+
],
|
|
4241
|
+
nestedByModifierId: {
|
|
4242
|
+
[mainBurger]: [
|
|
4243
|
+
{ modifierGroupId: 'mg_doneness', modifierIds: ['m_medium'] },
|
|
4244
|
+
{ modifierGroupId: 'mg_cheese', modifierIds: ['m_cheddar'] },
|
|
4245
|
+
],
|
|
4246
|
+
},
|
|
4247
|
+
});
|
|
4248
|
+
}`,
|
|
4249
|
+
"modifier-validation-error-handling": `// Surface MODIFIER_VALIDATION_FAILED to the user. The SDK throws
|
|
4250
|
+
// BrainerceError on HTTP 400; the structured envelope is on .details.
|
|
4251
|
+
import { client } from './brainerce';
|
|
4252
|
+
import type { ModifierSelection } from 'brainerce';
|
|
4253
|
+
|
|
4254
|
+
interface ModifierValidationError {
|
|
4255
|
+
code:
|
|
4256
|
+
| 'REQUIRED_GROUP_MISSING'
|
|
4257
|
+
| 'MIN_SELECTIONS_NOT_MET'
|
|
4258
|
+
| 'MAX_SELECTIONS_EXCEEDED'
|
|
4259
|
+
| 'SINGLE_GROUP_MULTIPLE_PICKS'
|
|
4260
|
+
| 'UNKNOWN_MODIFIER'
|
|
4261
|
+
| 'UNKNOWN_GROUP'
|
|
4262
|
+
| 'MODIFIER_DISABLED_FOR_VARIANT'
|
|
4263
|
+
| 'MODIFIER_NOT_AVAILABLE'
|
|
4264
|
+
| 'NESTED_DEPTH_EXCEEDED'
|
|
4265
|
+
| 'NESTED_REQUIRES_PRODUCT_REF'
|
|
4266
|
+
| 'INVALID_PRICE_DELTA'
|
|
4267
|
+
| 'MODIFIER_PRICE_FLOOR_VIOLATED';
|
|
4268
|
+
message: string;
|
|
4269
|
+
modifierGroupId?: string;
|
|
4270
|
+
modifierId?: string;
|
|
4271
|
+
}
|
|
4272
|
+
|
|
4273
|
+
async function tryAddToCart(productId: string, selections: ModifierSelection[]) {
|
|
4274
|
+
try {
|
|
4275
|
+
await client.smartAddToCart({ productId, quantity: 1, selections });
|
|
4276
|
+
return { ok: true as const };
|
|
4277
|
+
} catch (err) {
|
|
4278
|
+
const e = err as {
|
|
4279
|
+
statusCode?: number;
|
|
4280
|
+
details?: { code?: string; errors?: ModifierValidationError[] };
|
|
4281
|
+
};
|
|
4282
|
+
|
|
4283
|
+
if (e.statusCode === 400 && e.details?.code === 'MODIFIER_VALIDATION_FAILED') {
|
|
4284
|
+
// Surface each issue inline. groupId / modifierId let you highlight the
|
|
4285
|
+
// specific control the customer needs to fix.
|
|
4286
|
+
const issues = e.details.errors ?? [];
|
|
4287
|
+
const messages = issues.map((i) => formatIssue(i));
|
|
4288
|
+
return { ok: false as const, issues, messages };
|
|
4289
|
+
}
|
|
4290
|
+
|
|
4291
|
+
// Some other error (network, 500, etc.) \u2014 re-throw to a generic handler.
|
|
4292
|
+
throw err;
|
|
4293
|
+
}
|
|
4294
|
+
}
|
|
4295
|
+
|
|
4296
|
+
function formatIssue(issue: ModifierValidationError): string {
|
|
4297
|
+
switch (issue.code) {
|
|
4298
|
+
case 'REQUIRED_GROUP_MISSING':
|
|
4299
|
+
return 'Please pick at least one option from this group.';
|
|
4300
|
+
case 'MIN_SELECTIONS_NOT_MET':
|
|
4301
|
+
case 'MAX_SELECTIONS_EXCEEDED':
|
|
4302
|
+
case 'SINGLE_GROUP_MULTIPLE_PICKS':
|
|
4303
|
+
return issue.message; // server's message already says "Pick at least N" / "Pick at most N"
|
|
4304
|
+
case 'UNKNOWN_MODIFIER':
|
|
4305
|
+
case 'UNKNOWN_GROUP':
|
|
4306
|
+
case 'MODIFIER_DISABLED_FOR_VARIANT':
|
|
4307
|
+
case 'MODIFIER_NOT_AVAILABLE':
|
|
4308
|
+
// The catalog moved under us \u2014 refetch the product and ask the customer
|
|
4309
|
+
// to re-pick. These codes mean the option simply isn't valid anymore.
|
|
4310
|
+
return 'This option is no longer available \u2014 please refresh.';
|
|
4311
|
+
case 'NESTED_DEPTH_EXCEEDED':
|
|
4312
|
+
case 'NESTED_REQUIRES_PRODUCT_REF':
|
|
4313
|
+
return issue.message; // bug in your renderer \u2014 never go past 3 levels
|
|
4314
|
+
case 'INVALID_PRICE_DELTA':
|
|
4315
|
+
return issue.message; // shouldn't happen for SDK callers \u2014 server-validated on save
|
|
4316
|
+
case 'MODIFIER_PRICE_FLOOR_VIOLATED':
|
|
4317
|
+
// The server reports a generic message \u2014 internals never leak. Show a
|
|
4318
|
+
// friendly error and let the customer remove a downsell.
|
|
4319
|
+
return 'Cannot apply more discounts on this item.';
|
|
4320
|
+
}
|
|
4321
|
+
}
|
|
4322
|
+
|
|
4323
|
+
// CLIENT-SIDE PRE-FLIGHT (UX):
|
|
4324
|
+
// You can mirror these checks before calling addToCart to give faster
|
|
4325
|
+
// feedback, but the SERVER is authoritative. Always treat the 400 envelope
|
|
4326
|
+
// as the source of truth.
|
|
4327
|
+
function preflightSelections(
|
|
4328
|
+
groups: { id: string; name: string; min: number; max?: number | null; required: boolean; selectionType: 'SINGLE' | 'MULTIPLE'; }[],
|
|
4329
|
+
selections: Record<string, string[]>
|
|
4330
|
+
): string | null {
|
|
4331
|
+
for (const g of groups) {
|
|
4332
|
+
if (g.max === 0) continue; // disabled-for-variant
|
|
4333
|
+
const picks = selections[g.id] ?? [];
|
|
4334
|
+
if (g.required && picks.length === 0) return \`\${g.name} is required\`;
|
|
4335
|
+
if (picks.length < g.min) return \`Pick at least \${g.min} from \${g.name}\`;
|
|
4336
|
+
if (g.max != null && picks.length > g.max) return \`Pick at most \${g.max} from \${g.name}\`;
|
|
4337
|
+
if (g.selectionType === 'SINGLE' && picks.length > 1) return \`Only one option allowed in \${g.name}\`;
|
|
4338
|
+
}
|
|
4339
|
+
return null;
|
|
4340
|
+
}`,
|
|
4341
|
+
"checkout-price-drift": `// PRICE_DRIFT \u2014 a product's price changed between add-to-cart and checkout.
|
|
4342
|
+
// The server returns HTTP 400 with code "PRICE_DRIFT" when it detects that
|
|
4343
|
+
// the stored unitPrice no longer matches the live price.
|
|
4344
|
+
//
|
|
4345
|
+
// Strategy: catch the error \u2192 show "prices updated" dialog \u2192
|
|
4346
|
+
// call refreshCartSnapshots() to re-snapshot all live prices \u2192
|
|
4347
|
+
// retry createCheckout once.
|
|
4348
|
+
|
|
4349
|
+
import { client } from './brainerce-client';
|
|
4350
|
+
|
|
4351
|
+
interface CheckoutOptions {
|
|
4352
|
+
cartId: string;
|
|
4353
|
+
shippingAddress: object;
|
|
4354
|
+
shippingMethodId: string;
|
|
4355
|
+
paymentProviderId: string;
|
|
4356
|
+
}
|
|
4357
|
+
|
|
4358
|
+
async function createCheckoutSafe(opts: CheckoutOptions) {
|
|
4359
|
+
try {
|
|
4360
|
+
return await client.createCheckout({
|
|
4361
|
+
cartId: opts.cartId,
|
|
4362
|
+
shippingAddress: opts.shippingAddress,
|
|
4363
|
+
shippingMethodId: opts.shippingMethodId,
|
|
4364
|
+
paymentProviderId: opts.paymentProviderId,
|
|
4365
|
+
});
|
|
4366
|
+
} catch (err: unknown) {
|
|
4367
|
+
if (!isApiError(err, 'PRICE_DRIFT')) throw err;
|
|
4368
|
+
|
|
4369
|
+
// Prices changed \u2014 refresh snapshots then retry once.
|
|
4370
|
+
// refreshCartSnapshots updates every cart item's unitPrice to the current
|
|
4371
|
+
// live price (base price + any modifier deltas) so the next checkout call
|
|
4372
|
+
// will pass the drift guard.
|
|
4373
|
+
await client.refreshCartSnapshots(opts.cartId);
|
|
4374
|
+
|
|
4375
|
+
// After refreshing, re-fetch the cart so your UI shows the updated prices
|
|
4376
|
+
// before you retry, giving the customer a chance to review.
|
|
4377
|
+
const updatedCart = await client.getCart(opts.cartId);
|
|
4378
|
+
|
|
4379
|
+
// Signal to the UI layer that prices changed so it can show a dialog.
|
|
4380
|
+
throw Object.assign(new Error('PRICES_UPDATED'), {
|
|
4381
|
+
code: 'PRICES_UPDATED',
|
|
4382
|
+
updatedCart,
|
|
4383
|
+
});
|
|
4384
|
+
}
|
|
4385
|
+
}
|
|
4386
|
+
|
|
4387
|
+
function isApiError(err: unknown, code: string): boolean {
|
|
4388
|
+
return (
|
|
4389
|
+
typeof err === 'object' &&
|
|
4390
|
+
err !== null &&
|
|
4391
|
+
'code' in err &&
|
|
4392
|
+
(err as { code: string }).code === code
|
|
4393
|
+
);
|
|
4394
|
+
}
|
|
4395
|
+
|
|
4396
|
+
// --- UI integration example (framework-neutral) ---
|
|
4397
|
+
//
|
|
4398
|
+
// async function handlePlaceOrder() {
|
|
4399
|
+
// try {
|
|
4400
|
+
// const checkout = await createCheckoutSafe({ cartId, ... });
|
|
4401
|
+
// router.push(\`/order-confirmation?checkoutId=\${checkout.id}\`);
|
|
4402
|
+
// } catch (err: unknown) {
|
|
4403
|
+
// if (isApiError(err, 'PRICES_UPDATED')) {
|
|
4404
|
+
// const { updatedCart } = err as { updatedCart: Cart };
|
|
4405
|
+
// showPricesUpdatedDialog(updatedCart); // show diff, let user confirm
|
|
4406
|
+
// return;
|
|
4407
|
+
// }
|
|
4408
|
+
// showGenericError(err);
|
|
4409
|
+
// }
|
|
4410
|
+
// }
|
|
4411
|
+
//
|
|
4412
|
+
// The dialog should:
|
|
4413
|
+
// 1. Show which items changed price (compare old vs new unitPrice)
|
|
4414
|
+
// 2. Offer "Continue with new prices" (calls createCheckoutSafe again)
|
|
4415
|
+
// and "Go back to cart" (returns to cart page)
|
|
4416
|
+
// 3. NOT silently retry \u2014 the customer must acknowledge the price change.`,
|
|
4417
|
+
"cart-item-modifier-display": `// Rendering cart items that have modifier selections.
|
|
4418
|
+
// Each cart item has a \`modifiers\` array \u2014 each entry records the modifier
|
|
4419
|
+
// name, the price delta at time of add-to-cart, and whether it was free
|
|
4420
|
+
// (inside the group's freeQuantity allowance).
|
|
4421
|
+
//
|
|
4422
|
+
// Typical display:
|
|
4423
|
+
// Margherita \u20AA45.00
|
|
4424
|
+
// Extra cheese +\u20AA5.00
|
|
4425
|
+
// Mushrooms free
|
|
4426
|
+
// No onions \u2014
|
|
4427
|
+
// \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
|
|
4428
|
+
// Base \u20AA45.00 + Modifiers \u20AA5.00 = \u20AA50.00
|
|
4429
|
+
|
|
4430
|
+
import { formatPrice } from 'brainerce';
|
|
4431
|
+
|
|
4432
|
+
interface CartItemModifier {
|
|
4433
|
+
modifierId: string;
|
|
4434
|
+
name: string;
|
|
4435
|
+
priceDeltaAtTime: number; // in store currency units, e.g. 5.00
|
|
4436
|
+
freeApplied: boolean; // true when inside the group's freeQuantity
|
|
4437
|
+
}
|
|
4438
|
+
|
|
4439
|
+
interface CartItem {
|
|
4440
|
+
id: string;
|
|
4441
|
+
name: string;
|
|
4442
|
+
unitPrice: number; // base + all chargeable modifier deltas, as stored
|
|
4443
|
+
quantity: number;
|
|
4444
|
+
modifiers?: CartItemModifier[];
|
|
4445
|
+
currency: string; // e.g. 'ILS', 'USD'
|
|
4446
|
+
}
|
|
4447
|
+
|
|
4448
|
+
function renderCartItem(item: CartItem): string {
|
|
4449
|
+
const currency = item.currency;
|
|
4450
|
+
const lines: string[] = [];
|
|
4451
|
+
|
|
4452
|
+
lines.push(\`\${item.name} \${formatPrice(item.unitPrice, { currency })}\`);
|
|
4453
|
+
|
|
4454
|
+
const chargeableModifiers = (item.modifiers ?? []).filter(
|
|
4455
|
+
(m) => !m.freeApplied && m.priceDeltaAtTime !== 0
|
|
4456
|
+
);
|
|
4457
|
+
const freeModifiers = (item.modifiers ?? []).filter((m) => m.freeApplied);
|
|
4458
|
+
const zeroModifiers = (item.modifiers ?? []).filter(
|
|
4459
|
+
(m) => !m.freeApplied && m.priceDeltaAtTime === 0
|
|
4460
|
+
);
|
|
4461
|
+
|
|
4462
|
+
for (const m of chargeableModifiers) {
|
|
4463
|
+
const sign = m.priceDeltaAtTime > 0 ? '+' : '';
|
|
4464
|
+
lines.push(\` \${m.name} \${sign}\${formatPrice(m.priceDeltaAtTime, { currency })}\`);
|
|
4465
|
+
}
|
|
4466
|
+
for (const m of freeModifiers) {
|
|
4467
|
+
lines.push(\` \${m.name} free\`);
|
|
4468
|
+
}
|
|
4469
|
+
for (const m of zeroModifiers) {
|
|
4470
|
+
lines.push(\` \${m.name} \u2014\`);
|
|
4471
|
+
}
|
|
4472
|
+
|
|
4473
|
+
// Price breakdown: only show when there are chargeable modifiers
|
|
4474
|
+
const modifiersTotal = chargeableModifiers.reduce(
|
|
4475
|
+
(sum, m) => sum + m.priceDeltaAtTime,
|
|
4476
|
+
0
|
|
4477
|
+
);
|
|
4478
|
+
if (modifiersTotal !== 0) {
|
|
4479
|
+
// unitPrice already includes modifiers \u2014 derive base for display only
|
|
4480
|
+
const basePrice = item.unitPrice - modifiersTotal;
|
|
4481
|
+
const base = formatPrice(basePrice, { currency }) as string;
|
|
4482
|
+
const mods = formatPrice(modifiersTotal, { currency }) as string;
|
|
4483
|
+
const total = formatPrice(item.unitPrice, { currency }) as string;
|
|
4484
|
+
lines.push(\`Base \${base} + Modifiers \${mods} = \${total}\`);
|
|
4485
|
+
}
|
|
4486
|
+
|
|
4487
|
+
return lines.join('\\n');
|
|
4488
|
+
}
|
|
4489
|
+
|
|
4490
|
+
// IMPORTANT: unitPrice already includes all chargeable modifier deltas.
|
|
4491
|
+
// Do NOT add modifier prices on top of unitPrice when computing line totals \u2014
|
|
4492
|
+
// multiply unitPrice \xD7 quantity directly.
|
|
4493
|
+
function lineTotal(item: CartItem): number {
|
|
4494
|
+
return item.unitPrice * item.quantity;
|
|
4495
|
+
}`
|
|
3162
4496
|
};
|
|
3163
4497
|
async function handleGetCodeExample(args) {
|
|
3164
4498
|
const snippet = SNIPPETS[args.operation];
|
|
@@ -3274,13 +4608,27 @@ function getCandidateApiUrls() {
|
|
|
3274
4608
|
|
|
3275
4609
|
// src/tools/get-store-info.ts
|
|
3276
4610
|
var GET_STORE_INFO_NAME = "get-store-info";
|
|
3277
|
-
var GET_STORE_INFO_DESCRIPTION = "Fetch live store information from the Brainerce API using a
|
|
4611
|
+
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.";
|
|
3278
4612
|
var GET_STORE_INFO_SCHEMA = {
|
|
3279
|
-
|
|
4613
|
+
salesChannelId: import_zod4.z.string().optional().describe("Sales channel ID (starts with vc_)"),
|
|
4614
|
+
/** @deprecated alias of salesChannelId */
|
|
4615
|
+
connectionId: import_zod4.z.string().optional().describe("Deprecated alias of salesChannelId \u2014 kept for backwards compat")
|
|
3280
4616
|
};
|
|
3281
4617
|
async function handleGetStoreInfo(args) {
|
|
4618
|
+
const id = args.salesChannelId ?? args.connectionId;
|
|
4619
|
+
if (!id) {
|
|
4620
|
+
return {
|
|
4621
|
+
content: [{ type: "text", text: "Error: salesChannelId is required" }],
|
|
4622
|
+
isError: true
|
|
4623
|
+
};
|
|
4624
|
+
}
|
|
4625
|
+
if (!args.salesChannelId && args.connectionId) {
|
|
4626
|
+
console.warn(
|
|
4627
|
+
"get-store-info: `connectionId` is deprecated \u2014 use `salesChannelId` instead"
|
|
4628
|
+
);
|
|
4629
|
+
}
|
|
3282
4630
|
try {
|
|
3283
|
-
const resolved = await resolveStoreInfo(
|
|
4631
|
+
const resolved = await resolveStoreInfo(id, getCandidateApiUrls());
|
|
3284
4632
|
return {
|
|
3285
4633
|
content: [
|
|
3286
4634
|
{
|
|
@@ -3293,7 +4641,8 @@ async function handleGetStoreInfo(args) {
|
|
|
3293
4641
|
storeName: resolved.info.storeName,
|
|
3294
4642
|
currency: resolved.info.currency,
|
|
3295
4643
|
language: resolved.info.language,
|
|
3296
|
-
|
|
4644
|
+
salesChannelId: id,
|
|
4645
|
+
connectionId: id,
|
|
3297
4646
|
apiBaseUrl: resolved.apiBaseUrl
|
|
3298
4647
|
},
|
|
3299
4648
|
null,
|
|
@@ -3378,9 +4727,11 @@ async function resolveStoreCapabilities(connectionId, candidateUrls) {
|
|
|
3378
4727
|
|
|
3379
4728
|
// src/tools/get-store-capabilities.ts
|
|
3380
4729
|
var GET_STORE_CAPABILITIES_NAME = "get-store-capabilities";
|
|
3381
|
-
var GET_STORE_CAPABILITIES_DESCRIPTION = "Get live store capabilities and configured features for a
|
|
4730
|
+
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.";
|
|
3382
4731
|
var GET_STORE_CAPABILITIES_SCHEMA = {
|
|
3383
|
-
|
|
4732
|
+
salesChannelId: import_zod5.z.string().optional().describe("Sales channel ID (starts with vc_)"),
|
|
4733
|
+
/** @deprecated alias of salesChannelId */
|
|
4734
|
+
connectionId: import_zod5.z.string().optional().describe("Deprecated alias of salesChannelId \u2014 kept for backwards compat")
|
|
3384
4735
|
};
|
|
3385
4736
|
function formatCapabilities(caps) {
|
|
3386
4737
|
const lines = [];
|
|
@@ -3493,15 +4844,27 @@ function formatCapabilities(caps) {
|
|
|
3493
4844
|
}
|
|
3494
4845
|
if (suggestions.length === 0) {
|
|
3495
4846
|
suggestions.push(
|
|
3496
|
-
"Start by building the required features. Use get-required-features with this
|
|
4847
|
+
"Start by building the required features. Use get-required-features with this salesChannelId for the checklist."
|
|
3497
4848
|
);
|
|
3498
4849
|
}
|
|
3499
4850
|
suggestions.forEach((s) => lines.push(`- ${s}`));
|
|
3500
4851
|
return lines.join("\n");
|
|
3501
4852
|
}
|
|
3502
4853
|
async function handleGetStoreCapabilities(args) {
|
|
4854
|
+
const id = args.salesChannelId ?? args.connectionId;
|
|
4855
|
+
if (!id) {
|
|
4856
|
+
return {
|
|
4857
|
+
content: [{ type: "text", text: "Error: salesChannelId is required" }],
|
|
4858
|
+
isError: true
|
|
4859
|
+
};
|
|
4860
|
+
}
|
|
4861
|
+
if (!args.salesChannelId && args.connectionId) {
|
|
4862
|
+
console.warn(
|
|
4863
|
+
"get-store-capabilities: `connectionId` is deprecated \u2014 use `salesChannelId` instead"
|
|
4864
|
+
);
|
|
4865
|
+
}
|
|
3503
4866
|
try {
|
|
3504
|
-
const resolved = await resolveStoreCapabilities(
|
|
4867
|
+
const resolved = await resolveStoreCapabilities(id, getCandidateApiUrls());
|
|
3505
4868
|
return {
|
|
3506
4869
|
content: [{ type: "text", text: formatCapabilities(resolved.capabilities) }]
|
|
3507
4870
|
};
|
|
@@ -3606,7 +4969,7 @@ var RULES = {
|
|
|
3606
4969
|
tokens: {
|
|
3607
4970
|
title: "Auth tokens & BFF pattern",
|
|
3608
4971
|
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\`.
|
|
3609
|
-
- NEVER put the admin API key (\`brainerce_*\`) in client code. It is a server-only secret. Client code uses \`connectionId\` or storefront endpoints.
|
|
4972
|
+
- 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.
|
|
3610
4973
|
- 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.
|
|
3611
4974
|
- 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.`
|
|
3612
4975
|
},
|
|
@@ -3899,18 +5262,20 @@ ${flow.body}`
|
|
|
3899
5262
|
// src/tools/get-required-features.ts
|
|
3900
5263
|
var import_zod9 = require("zod");
|
|
3901
5264
|
var GET_REQUIRED_FEATURES_NAME = "get-required-features";
|
|
3902
|
-
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
|
|
5265
|
+
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.";
|
|
3903
5266
|
var GET_REQUIRED_FEATURES_SCHEMA = {
|
|
3904
|
-
|
|
3905
|
-
"
|
|
3906
|
-
)
|
|
5267
|
+
salesChannelId: import_zod9.z.string().optional().describe(
|
|
5268
|
+
"Sales channel ID (starts with vc_). Optional \u2014 without it, returns the generic checklist."
|
|
5269
|
+
),
|
|
5270
|
+
/** @deprecated alias of salesChannelId */
|
|
5271
|
+
connectionId: import_zod9.z.string().optional().describe("Deprecated alias of salesChannelId \u2014 kept for backwards compat")
|
|
3907
5272
|
};
|
|
3908
5273
|
var FEATURES = [
|
|
3909
5274
|
{
|
|
3910
5275
|
id: "browse-products",
|
|
3911
5276
|
title: "Browse, filter, and search products",
|
|
3912
|
-
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.",
|
|
3913
|
-
sdk: "client.getProducts({ page, limit, filters, sort }), client.searchProducts(query)",
|
|
5277
|
+
description: "Users can list products with pagination, apply category / price / attribute / custom-field filters, sort by name / price / newest, and run a search with autocomplete. Custom-field filters surface metafield definitions whose filterable=true (SELECT, MULTI_SELECT, BOOLEAN). Must handle empty states and loading states.",
|
|
5278
|
+
sdk: "client.getProducts({ page, limit, filters, sort, metafields }), client.getPublicMetafieldDefinitions(), client.searchProducts(query)",
|
|
3914
5279
|
mandatory: "mandatory"
|
|
3915
5280
|
},
|
|
3916
5281
|
{
|
|
@@ -4125,10 +5490,16 @@ function renderCapabilitiesSummary(caps) {
|
|
|
4125
5490
|
return lines.join("\n");
|
|
4126
5491
|
}
|
|
4127
5492
|
async function handleGetRequiredFeatures(args) {
|
|
5493
|
+
const id = args.salesChannelId ?? args.connectionId;
|
|
5494
|
+
if (!args.salesChannelId && args.connectionId) {
|
|
5495
|
+
console.warn(
|
|
5496
|
+
"get-required-features: `connectionId` is deprecated \u2014 use `salesChannelId` instead"
|
|
5497
|
+
);
|
|
5498
|
+
}
|
|
4128
5499
|
let caps = null;
|
|
4129
|
-
if (
|
|
5500
|
+
if (id) {
|
|
4130
5501
|
try {
|
|
4131
|
-
const resolved = await resolveStoreCapabilities(
|
|
5502
|
+
const resolved = await resolveStoreCapabilities(id, getCandidateApiUrls());
|
|
4132
5503
|
caps = resolved.capabilities;
|
|
4133
5504
|
} catch {
|
|
4134
5505
|
caps = null;
|
|
@@ -4140,11 +5511,11 @@ async function handleGetRequiredFeatures(args) {
|
|
|
4140
5511
|
);
|
|
4141
5512
|
if (caps) {
|
|
4142
5513
|
sections.push(renderCapabilitiesSummary(caps));
|
|
4143
|
-
} else if (
|
|
5514
|
+
} else if (id) {
|
|
4144
5515
|
sections.push(
|
|
4145
5516
|
`## Live store capabilities
|
|
4146
5517
|
|
|
4147
|
-
Could not fetch live capabilities for \`${
|
|
5518
|
+
Could not fetch live capabilities for \`${id}\`. Proceeding with the generic checklist. Call \`get-store-capabilities\` separately if you want a targeted checklist.`
|
|
4148
5519
|
);
|
|
4149
5520
|
}
|
|
4150
5521
|
sections.push("## Features");
|