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