@brainerce/mcp-server 3.1.0 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -91,7 +91,7 @@ function getTypeQuickReference() {
91
91
  | OAuth | \`provider.name\` | \`provider\` | it IS the string: \`'GOOGLE' | 'FACEBOOK' | 'GITHUB'\` |
92
92
  | OAuth | \`oauth.url\` | \`oauth.authorizationUrl\` | |
93
93
  | Description | \`getDescriptionContent()\` | \`{ html } | { text } | null\` | check \`'html' in content\` before use |
94
- | \`smartGetCart()\` | always use \`.id\` | check \`'id' in cart\` | returns \`Cart | LocalCart\` |
94
+ | \`smartGetCart()\` | N/A | \`cart.id\` | returns \`CartWithIncludes\` (extends \`Cart\`); pass \`{ include: [...] }\` for extras |
95
95
  | \`startGuestCheckout()\` | always use \`.checkoutId\` | check \`result.tracked\` | discriminated union |
96
96
 
97
97
  **Rules:**
@@ -99,8 +99,8 @@ function getTypeQuickReference() {
99
99
  - CartItem/CheckoutLineItem = **NESTED** (\`item.product.name\`, \`item.unitPrice\`)
100
100
  - OrderItem = **FLAT** (\`item.name\`, \`item.price\`, \`item.totalPrice\`, \`item.image\`)
101
101
  - Cart has NO \`.total\` \u2014 use \`getCartTotals(cart)\`
102
- - \`getCartTotals()\` only works with server \`Cart\`, NOT \`LocalCart\`
103
- - \`smartGetCart()\` returns \`Cart | LocalCart\` \u2014 check \`'id' in cart\` before using Cart fields
102
+ - \`getCartTotals()\` works with server \`Cart\` (all carts are server-side now)
103
+ - \`smartGetCart()\` returns \`CartWithIncludes\` \u2014 always a server cart; pass \`{ include: ['recommendations', 'upgrades', 'bundles'] }\` for extras
104
104
  - \`startGuestCheckout()\` returns discriminated union \u2014 check \`result.tracked\` before \`.checkoutId\`
105
105
  - \`SetShippingAddressDto.email\` is **required** \u2014 always include email
106
106
 
@@ -228,7 +228,7 @@ async function startCheckout() {
228
228
  6. Select shipping method \u2192 \`selectShippingMethod()\`
229
229
  6b. (Optional) Set checkout custom fields \u2192 \`setCheckoutCustomFields()\` \u2014 surcharges auto-calculated
230
230
  7. Create payment intent \u2192 \`createPaymentIntent()\` \u2192 returns \`{ clientSecret, provider }\`
231
- 8. Branch on \`provider\`: \`'stripe'\` \u2192 Stripe Elements, \`'grow'\` \u2192 iframe, \`'paypal'\` \u2192 PayPal Buttons
231
+ 8. Branch on the **\`clientSdk.renderType\`** returned by \`createPaymentIntent()\` \u2014 NEVER hard-code by provider name. Values: \`'sdk-widget'\` (Stripe, PayPal, Grow), \`'iframe'\` (Cardcom hosted or Brainerce-hosted embed \u2014 detect via URL path), \`'redirect'\`, \`'sandbox'\`, \`'embedded-fields'\` (reserved).
232
232
  9. **Order is created AUTOMATICALLY after payment succeeds (via webhook) \u2014 for ALL providers!**
233
233
 
234
234
  ### Checkout Page Component
@@ -331,8 +331,8 @@ function CheckoutPage() {
331
331
  </div>
332
332
  );
333
333
  }
334
- if (paymentData.provider === 'grow') {
335
- return <GrowPaymentForm paymentUrl={paymentData.clientSecret} checkoutId={paymentData.checkoutId} />;
334
+ if (paymentData.clientSdk?.renderType === 'iframe') {
335
+ return <PaymentIframe clientSecret={paymentData.clientSecret} checkoutId={paymentData.checkoutId} />;
336
336
  }
337
337
  if (paymentData.provider === 'paypal' && paypalClientId) {
338
338
  return <PayPalPaymentForm clientId={paypalClientId} orderId={paymentData.clientSecret} checkoutId={paymentData.checkoutId} />;
@@ -388,26 +388,82 @@ function StripePaymentForm({ checkoutId }: { checkoutId: string }) {
388
388
  }
389
389
  \`\`\`
390
390
 
391
- ### Grow Payment Form (Israeli stores \u2014 iframe based, no SDK needed)
391
+ ### Iframe-Based Providers (Cardcom, legacy Grow, etc.)
392
+
393
+ When \`clientSdk.renderType === 'iframe'\`, the payment intent returns a \`clientSecret\` which is a URL to load in an iframe. There are TWO flavors of iframe rendering you must handle:
394
+
395
+ **Flavor A \u2014 Brainerce-hosted embed (URL path contains \`/embed/\`):** The iframe loads a Brainerce-branded compact form (e.g. Cardcom OpenFields). Render it **INLINE** inside your checkout flow (next to the order summary) \u2014 NO modal, NO dark overlay. The embed page posts messages to resize itself and to request top-level navigation (e.g. for Bit express-pay buttons).
396
+
397
+ **Flavor B \u2014 Provider-hosted page (any other URL):** The iframe loads a full provider-branded page with its own header/chrome. Render inside a **modal overlay** so it doesn't fight your checkout layout.
398
+
399
+ Detect flavor by URL path \u2014 works across localhost/staging/prod without a domain list:
392
400
 
393
401
  \`\`\`typescript
394
- function GrowPaymentForm({ paymentUrl, checkoutId }: { paymentUrl: string; checkoutId: string }) {
395
- const [iframeLoaded, setIframeLoaded] = useState(false);
402
+ function PaymentIframe({ clientSecret, checkoutId }: { clientSecret: string; checkoutId: string }) {
403
+ const [height, setHeight] = useState(540); // default before resize message arrives
404
+ const isBrainerceEmbed = (() => {
405
+ try { return new URL(clientSecret).pathname.includes('/embed/'); }
406
+ catch { return false; }
407
+ })();
408
+
409
+ useEffect(() => {
410
+ function handleMessage(e: MessageEvent) {
411
+ const data = e.data as { type?: string; height?: number; url?: string };
412
+ if (data?.type === 'brainerce:resize' && typeof data.height === 'number') {
413
+ setHeight(data.height);
414
+ }
415
+ if (data?.type === 'brainerce:redirect' && typeof data.url === 'string') {
416
+ // Top-level navigation (e.g. Bit). ALWAYS validate against an allowlist
417
+ // before navigating \u2014 never trust the URL blindly.
418
+ if (isTrustedPaymentUrl(data.url)) { window.top!.location.href = data.url; }
419
+ }
420
+ if (data?.type === 'brainerce:payment-complete') {
421
+ // Payment done \u2014 redirect to confirmation page which verifies server-side
422
+ window.location.href = \`/order-confirmation?checkout_id=\${checkoutId}\`;
423
+ }
424
+ }
425
+ window.addEventListener('message', handleMessage);
426
+ return () => window.removeEventListener('message', handleMessage);
427
+ }, [checkoutId]);
428
+
429
+ if (isBrainerceEmbed) {
430
+ // Inline: part of the checkout flow, no overlay
431
+ return (
432
+ <div className="w-full">
433
+ <iframe
434
+ src={clientSecret}
435
+ style={{ width: '100%', height, border: 0, transition: 'height 0.2s ease-out' }}
436
+ title="Payment"
437
+ allow="payment"
438
+ />
439
+ </div>
440
+ );
441
+ }
442
+
443
+ // Provider-hosted page: modal overlay
396
444
  return (
397
- <div className="w-full">
398
- {!iframeLoaded && <div className="flex items-center justify-center py-12"><span>Loading payment form...</span></div>}
399
- <iframe
400
- src={paymentUrl}
401
- onLoad={() => setIframeLoaded(true)}
402
- style={{ width: '100%', minHeight: '600px', border: 'none', display: iframeLoaded ? 'block' : 'none' }}
403
- allow="payment"
404
- />
405
- <p className="text-center text-sm text-gray-500 mt-4">
406
- Having trouble? <a href={paymentUrl} target="_blank" rel="noopener noreferrer" className="text-blue-600 underline">Open payment in new tab</a>
407
- </p>
445
+ <div className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 py-6 overflow-y-auto">
446
+ <div className="bg-white rounded-2xl shadow-2xl w-full max-w-4xl mx-4">
447
+ <iframe
448
+ src={clientSecret}
449
+ style={{ width: '100%', height: '90vh', minHeight: 700, border: 0 }}
450
+ title="Payment"
451
+ allow="payment"
452
+ />
453
+ </div>
408
454
  </div>
409
455
  );
410
456
  }
457
+
458
+ // Allowlist of origins you trust for top-level payment redirects.
459
+ function isTrustedPaymentUrl(url: string): boolean {
460
+ try {
461
+ const u = new URL(url);
462
+ if (u.protocol !== 'https:') return false;
463
+ // Add your provider hostnames here. Example: Cardcom for Bit express-pay.
464
+ return u.hostname === 'cardcom.solutions' || u.hostname.endsWith('.cardcom.solutions');
465
+ } catch { return false; }
466
+ }
411
467
  \`\`\`
412
468
 
413
469
  ### PayPal Payment Form
@@ -677,12 +733,23 @@ const growProvider = providers.find(p => p.provider === 'grow');
677
733
  const paypalProvider = providers.find(p => p.provider === 'paypal');
678
734
  \`\`\`
679
735
 
680
- Each provider has: \`id\`, \`provider\` ('stripe'|'grow'|'paypal'|'sandbox'), \`name\`, \`publicKey\`, \`stripeAccountId\` (Stripe only), \`supportedMethods\`, \`testMode\`, \`isDefault\`.
736
+ Each provider has: \`id\`, \`provider\` (flexible string \u2014 \`'stripe'\`, \`'grow'\`, \`'paypal'\`, \`'cardcom'\`, \`'sandbox'\`, and future providers), \`name\`, \`publicKey\`, \`stripeAccountId\` (Stripe only), \`supportedMethods\`, \`testMode\`, \`isDefault\`.
737
+
738
+ The payment intent returned from \`createPaymentIntent()\` includes a \`clientSdk\` object whose \`renderType\` tells you exactly how to render \u2014 **branch on THAT, not on the provider name**:
681
739
 
740
+ - \`renderType: 'sdk-widget'\` \u2014 load \`clientSdk.scriptUrl\`, mount into \`<div id={clientSdk.containerId}>\`. Used by Stripe, PayPal, Grow. The SDK paints its own form in your DOM.
741
+ - \`renderType: 'iframe'\` \u2014 render \`<iframe src={clientSecret}>\`. Two flavors detected by URL path:
742
+ - Path contains \`/embed/\` \u2192 Brainerce-hosted embed. Render **INLINE** (no modal). Listen for \`brainerce:resize\` / \`brainerce:redirect\` postMessages. Used by Cardcom (embedded mode).
743
+ - Any other URL \u2192 provider-hosted page. Render inside a **modal**. Used by Cardcom (hosted mode), legacy Grow iframe.
744
+ - \`renderType: 'redirect'\` \u2014 \`window.location.href = URL\`. Customer completes payment off-site and returns via SuccessRedirectUrl.
745
+ - \`renderType: 'sandbox'\` \u2014 show a "Complete Test Order" button; call \`completeGuestCheckout(checkoutId)\`. Orders are \`isTestOrder: true\`. Appears when \`sandboxPaymentsEnabled\` is true.
746
+ - \`renderType: 'embedded-fields'\` \u2014 reserved for future Stripe-Elements-style pattern (PCI micro-iframes for card/CVV mounted directly into the merchant form). **No provider ships this today** \u2014 handle via a "not supported yet" fallback.
747
+
748
+ Provider-specific install notes:
682
749
  - **Stripe:** \`npm install @stripe/stripe-js @stripe/react-stripe-js\` \u2014 \`loadStripe(publicKey, { stripeAccount })\`
683
- - **Grow:** No SDK \u2014 uses iframe with payment URL. Supports credit cards, Bit, Apple Pay, Google Pay.
684
750
  - **PayPal:** \`npm install @paypal/react-paypal-js\` \u2014 \`PayPalScriptProvider\` + \`PayPalButtons\`
685
- - **Sandbox:** No SDK needed \u2014 when \`sandboxPaymentsEnabled\` is true, \`getPaymentProviders()\` includes a sandbox provider with \`renderType: 'sandbox'\`. Show a "Complete Test Order" button. Call \`completeGuestCheckout(checkoutId)\` to finalize. Orders are marked \`isTestOrder: true\`.`;
751
+ - **Grow:** No SDK needed \u2014 JS SDK loaded via \`clientSdk.scriptUrl\`. Supports credit cards, Bit, Apple Pay, Google Pay.
752
+ - **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.`;
686
753
  }
687
754
  function getProductsSection(_currency) {
688
755
  return `## Products & Variants
@@ -713,10 +780,12 @@ When \`storeInfo.i18n.enabled\` is true, call \`client.setLocale(locale)\` once
713
780
  \`{ locale }\` params needed. Translated fields include: \`name\`,
714
781
  \`description\`, \`slug\`, \`seoTitle\`, \`seoDescription\`, plus
715
782
  \`categories[].name\`, \`brands[].name\`, \`tags[].name\`, \`variants[].name\`,
716
- \`productAttributeOptions[].attribute.name\`/\`attributeOption.name\`, and
717
- \`metafields[].value\`. No client-side overlay. See the full \`i18n\` section
718
- (\`get-sdk-docs\` topic \`i18n\`) for the \`[locale]\` route pattern, SDK setup,
719
- and the brand/tag badge examples.
783
+ \`variant.attributes\` keys and values (so \`getVariantOptions()\` returns
784
+ translated attribute/option names), \`productAttributeOptions[].attribute.name\`/
785
+ \`attributeOption.name\`, \`metafields[].value\`, cart item product/variant names,
786
+ and recommendation/bundle/bump product names. No client-side overlay. See the
787
+ full \`i18n\` section (\`get-sdk-docs\` topic \`i18n\`) for the \`[locale]\` route
788
+ pattern, SDK setup, and the brand/tag badge examples.
720
789
 
721
790
  ### Price Display (Use SDK Helper!)
722
791
 
@@ -853,6 +922,8 @@ const material = getProductMetafieldValue(product, 'material');
853
922
 
854
923
  Some products allow customers to provide input at purchase time (e.g., text on a cake, upload a logo). Check \`product.customizationFields\` and render input fields accordingly.
855
924
 
925
+ Merchants can also flag a \`MetafieldDefinition\` as \`appliesToAllProducts: true\` \u2014 those fields are folded into every product's \`customizationFields\` array automatically by the backend. Client code does not need to merge or union anything: always render exactly what's in \`product.customizationFields\`.
926
+
856
927
  \`\`\`typescript
857
928
  import { getProductCustomizationFields } from 'brainerce';
858
929
  import type { ProductCustomizationField } from 'brainerce';
@@ -911,7 +982,7 @@ function getCartSection(_currency) {
911
982
  ### Smart Cart Methods (RECOMMENDED)
912
983
 
913
984
  \`\`\`typescript
914
- const cart = await client.smartGetCart(); // Returns Cart | LocalCart
985
+ const cart = await client.smartGetCart(); // Returns CartWithIncludes (extends Cart)
915
986
  await client.smartAddToCart({
916
987
  productId: product.id,
917
988
  variantId: selectedVariant?.id,
@@ -1042,7 +1113,19 @@ const recs = (product as any).recommendations as ProductRecommendationsResponse
1042
1113
 
1043
1114
  ### Cart Page Features
1044
1115
 
1045
- **Cross-sell recommendations** (existing products the customer might also want):
1116
+ **Consolidated include** (fetch recommendations, upgrades, and bundles in one request):
1117
+ \`\`\`typescript
1118
+ import type { CartWithIncludes } from 'brainerce';
1119
+ const cart = await client.getCart(cartId, {
1120
+ include: ['recommendations', 'upgrades', 'bundles'],
1121
+ });
1122
+ // cart.recommendations \u2014 cross-sell recommendations
1123
+ // cart.upgrades \u2014 upgrade suggestions keyed by source product ID
1124
+ // cart.bundles \u2014 bundle offers
1125
+ // Also works with smartGetCart: client.smartGetCart({ include: ['recommendations', 'upgrades', 'bundles'] })
1126
+ \`\`\`
1127
+
1128
+ **Cross-sell recommendations** (or fetch individually for targeted refresh):
1046
1129
  \`\`\`typescript
1047
1130
  import type { CartRecommendationsResponse } from 'brainerce';
1048
1131
  const cartRecs = await client.getCartRecommendations(cartId, 4);
@@ -1134,6 +1217,110 @@ Always check the flag before rendering: \`if (storeInfo?.upsell?.featureName !==
1134
1217
  ProductRecommendation: \`id\`, \`name\`, \`slug\`, \`basePrice\`, \`salePrice\`, \`images\`, \`type\`, \`inventory\`, \`relationType\`, \`variants?\` (when variant selection needed).
1135
1218
  OrderBump/CartBundleOffer: includes \`requiresVariantSelection\` (boolean) and \`lockedVariant?\` (\`{ id, name, attributes }\`).`;
1136
1219
  }
1220
+ function getProductCustomizationFieldsSection() {
1221
+ return `## Product Customization Fields (buyer input on product page)
1222
+
1223
+ Some products let the buyer personalize them before adding to cart \u2014 engraving text, a photo upload, a color pick, a multi-select add-on list. The merchant defines these fields in the Brainerce dashboard; the storefront renders them, collects values, uploads any images, and passes everything as \`metadata\` on add-to-cart.
1224
+
1225
+ ### Where the fields come from
1226
+
1227
+ They arrive **embedded on the product response** \u2014 no extra API call:
1228
+
1229
+ \`\`\`typescript
1230
+ import type { Product, ProductCustomizationField, MetafieldType } from 'brainerce';
1231
+
1232
+ const product = await client.getProductBySlug(slug);
1233
+ const fields: ProductCustomizationField[] = product.customizationFields ?? [];
1234
+ // fields is sorted by position. If empty, render the product page normally.
1235
+ \`\`\`
1236
+
1237
+ Each field has:
1238
+ \`\`\`typescript
1239
+ {
1240
+ definitionId: string;
1241
+ key: string; // stable identifier \u2014 use this as the metadata key
1242
+ name: string; // display label (may be localized)
1243
+ description?: string | null;
1244
+ type: MetafieldType; // 'TEXT' | 'TEXTAREA' | 'NUMBER' | 'BOOLEAN' | 'DATE' | 'DATETIME' |
1245
+ // 'JSON' | 'URL' | 'COLOR' | 'DIMENSION' | 'WEIGHT' |
1246
+ // 'IMAGE' | 'GALLERY' | 'SELECT' | 'MULTI_SELECT'
1247
+ required: boolean;
1248
+ minLength?: number | null; // chars for TEXT/TEXTAREA; array size for MULTI_SELECT
1249
+ maxLength?: number | null;
1250
+ minValue?: number | null; // NUMBER / DIMENSION / WEIGHT
1251
+ maxValue?: number | null;
1252
+ enumValues?: string[]; // required for SELECT / MULTI_SELECT
1253
+ defaultValue?: string | null;
1254
+ position: number;
1255
+ }
1256
+ \`\`\`
1257
+
1258
+ ### Render one control per type
1259
+
1260
+ | type | Render as | Collected value |
1261
+ | --------------- | ---------------------------------------------------------- | ---------------------------- |
1262
+ | \`TEXT\` | \`<input type="text">\` | \`string\` |
1263
+ | \`TEXTAREA\` | \`<textarea>\` | \`string\` |
1264
+ | \`NUMBER\` | \`<input type="number">\` | \`number\` |
1265
+ | \`BOOLEAN\` | checkbox / switch | \`boolean\` |
1266
+ | \`DATE\` | \`<input type="date">\` | \`string\` (YYYY-MM-DD) |
1267
+ | \`DATETIME\` | \`<input type="datetime-local">\` | \`string\` (ISO 8601) |
1268
+ | \`URL\` | \`<input type="url">\` | \`string\` |
1269
+ | \`COLOR\` | \`<input type="color">\` | \`string\` (#RRGGBB) |
1270
+ | \`SELECT\` | \`<select>\` or radio group (use \`enumValues\`) | \`string\` (in enumValues) |
1271
+ | \`MULTI_SELECT\`| checkbox group (use \`enumValues\`) | \`string[]\` (every in enum) |
1272
+ | \`IMAGE\` | file input + upload via \`uploadCustomizationFile\` | \`string\` (URL) |
1273
+ | \`GALLERY\` | multi-file input + one upload per file | \`string[]\` (URLs) |
1274
+ | \`JSON\` | textarea; validate is parseable | \`string\` (JSON) |
1275
+
1276
+ ### Upload buyer-submitted images
1277
+
1278
+ \`IMAGE\` and \`GALLERY\` require uploading before add-to-cart:
1279
+
1280
+ \`\`\`typescript
1281
+ const { url } = await client.uploadCustomizationFile(file);
1282
+ // Server rules: image/* only, max 5 MB, 10 uploads/min per IP.
1283
+ // Files are retained for at least 7 days; after that, if the cart never
1284
+ // became an order, they are automatically deleted.
1285
+ \`\`\`
1286
+
1287
+ ### Add to cart with customization metadata
1288
+
1289
+ \`\`\`typescript
1290
+ // Collect values keyed by field.key \u2014 NOT by field.name or field.definitionId.
1291
+ const metadata: Record<string, unknown> = {
1292
+ engraving_text: 'Happy Birthday!', // TEXT
1293
+ frame_color: 'Gold', // SELECT (must be in enumValues)
1294
+ upload_photo: url, // IMAGE (URL from uploadCustomizationFile)
1295
+ addons: ['Gift wrap'], // MULTI_SELECT (always an array)
1296
+ };
1297
+
1298
+ await client.addToCart(cart.id, {
1299
+ productId: product.id,
1300
+ quantity: 1,
1301
+ metadata,
1302
+ });
1303
+ \`\`\`
1304
+
1305
+ Server validation (rejected with HTTP 400 on failure):
1306
+ - \`required: true\` \u2192 must be present and non-empty
1307
+ - \`TEXT\` / \`TEXTAREA\` \u2192 string; \`minLength\` / \`maxLength\` enforced as character count
1308
+ - \`NUMBER\` \u2192 \`minValue\` / \`maxValue\` enforced
1309
+ - \`SELECT\` \u2192 value must be one of \`enumValues\`
1310
+ - \`MULTI_SELECT\` \u2192 array; each element in \`enumValues\`; duplicates removed; \`minLength\` / \`maxLength\` enforced as array size
1311
+ - \`IMAGE\` / \`GALLERY\` \u2192 URL(s) must be from \`/customization-upload\` on this store
1312
+
1313
+ ### Order snapshot
1314
+
1315
+ When the order is created, each line's customization values are snapshotted onto the order item with \`{ key, name, type, value }\` \u2014 so later edits to the field definition don't orphan old orders. The merchant dashboard shows the values on the order detail page.
1316
+
1317
+ ### Common mistakes
1318
+
1319
+ - Passing values keyed by \`field.name\` instead of \`field.key\` \u2192 silently ignored (unknown key)
1320
+ - Sending a \`MULTI_SELECT\` as a string instead of \`string[]\` \u2192 HTTP 400
1321
+ - Using a raw external URL (not from \`/customization-upload\`) for \`IMAGE\` / \`GALLERY\` \u2192 HTTP 400
1322
+ - Assuming \`enumValues\` is present for all types \u2014 only \`SELECT\` / \`MULTI_SELECT\` require it`;
1323
+ }
1137
1324
  function getInventorySection() {
1138
1325
  return `## Inventory, Stock Display & Reservation Countdown
1139
1326
 
@@ -1390,7 +1577,50 @@ const { data: orders } = await client.getMyOrders({ page: 1, limit: 10 }); // Re
1390
1577
  // \u274C WRONG \u2014 these methods don't exist!
1391
1578
  client.getCustomerProfile();
1392
1579
  client.getCustomerOrders();
1393
- \`\`\``;
1580
+ \`\`\`
1581
+
1582
+ ### Order history should show more than just totals
1583
+
1584
+ A useful order card includes ALL of the following when the data is present. Each section is conditional \u2014 render nothing if the field is empty.
1585
+
1586
+ | Section | Source field | Notes |
1587
+ |---|---|---|
1588
+ | Header (number, status badge, date, total) | \`order.orderNumber\`, \`order.status\`, \`order.createdAt\`, \`order.totalAmount\` | Always present. |
1589
+ | Line items | \`order.items[]\` | Render image, name, qty, price. |
1590
+ | **Per-item customizations** | \`order.items[i].customizations\` | Map of { label, value, type }. Render by \`type\` \u2014 see table below. |
1591
+ | **Status timeline** | \`order.statusHistory\` | \`OrderStatusChange[]\`: \`{ status, at, note? }\`. Render as a vertical list. |
1592
+ | **Shipping address** | \`order.shippingAddress\` | Standard \`OrderAddress\` shape. |
1593
+ | **Tracking** | \`order.trackingNumber\`, \`order.trackingUrl\`, \`order.carrier\`, \`order.shippedAt\`, \`order.deliveredAt\` | Link out to \`trackingUrl\` when set. |
1594
+ | **Payment** | \`order.paymentMethod\`, \`order.financialStatus\` | Badge \`financialStatus\` (paid / pending / refunded / partially_refunded). |
1595
+ | Downloads | \`order.hasDownloads\` \u2192 \`client.getOrderDownloads(id)\` | Separate call; returns \`OrderDownloadLink[]\`. |
1596
+ | Financial summary | \`order.subtotal\`, \`order.appliedDiscounts\`, \`order.couponCode\` + \`couponDiscount\`, \`order.shippingAmount\`, \`order.taxAmount\`, \`order.totalAmount\` | Breakdown rows + final total. |
1597
+
1598
+ #### Rendering \`order.items[i].customizations\` by type
1599
+
1600
+ The map key is the metafield slug; the value is \`{ label, value, type }\`. Dispatch by \`type\`:
1601
+
1602
+ | Type | Value | Render |
1603
+ |---|---|---|
1604
+ | \`TEXT\`, \`TEXTAREA\`, \`URL\`, \`NUMBER\`, \`SELECT\` | string | Plain text (\`URL\` \u2192 anchor). |
1605
+ | \`BOOLEAN\` | \`"yes"\` / \`"no"\` | \u2713 / \u2717 |
1606
+ | \`MULTI_SELECT\` | \`string[]\` | Comma-separated. |
1607
+ | \`IMAGE\` | asset URL (string) | Thumbnail linking to full-size asset. |
1608
+ | \`GALLERY\` | \`string[]\` of URLs | Grid of thumbnails. |
1609
+ | \`COLOR\` | hex string | Swatch + hex text. |
1610
+ | \`DATE\` | ISO-8601 | \`toLocaleDateString()\` |
1611
+ | \`DATETIME\` | ISO-8601 | \`toLocaleString()\` |
1612
+ | Unknown | any | Plain text (defensive default). |
1613
+
1614
+ The storefront scaffolded by \`create-brainerce-store\` already implements this. See \`src/components/account/order-history.tsx\` + the sibling \`order-customizations.tsx\`, \`order-status-timeline.tsx\`, \`order-shipping-block.tsx\`, \`order-payment-block.tsx\` \u2014 mirror that structure.
1615
+
1616
+ ### Do NOT render
1617
+
1618
+ These fields are NOT returned to buyers by \`/customers/me/orders\`. If you see them in older examples, ignore them \u2014 they are for merchant/admin views only.
1619
+
1620
+ - \`order.accountId\`, \`order.storeId\`, \`order.customerId\` (already known on the request)
1621
+ - \`order.notes\` (internal merchant notes)
1622
+ - \`order.customFieldValues\` (order-level checkout fields \u2014 merchant concept; buyers see \`items[i].customizations\`)
1623
+ - \`order.appliedSurcharges\`, \`order.surchargeAmount\`, \`order.appliedRuleIds\`, \`order.downloadMeta\`, \`order.pickupLocationData\``;
1394
1624
  }
1395
1625
  function getOrderConfirmationSection() {
1396
1626
  return `## Order Confirmation Page (/order-confirmation) \u2014 REQUIRED!
@@ -1560,7 +1790,8 @@ overlay needed:
1560
1790
  - \`brands[].name\`
1561
1791
  - \`tags[].name\`
1562
1792
  - \`variants[].name\`
1563
- - \`productAttributeOptions[].attribute.name\`, \`attributeOption.name\`/\`value\`
1793
+ - \`variant.attributes\` keys and values \u2014 \`getVariantOptions(variant)\` returns translated attribute names and option values automatically
1794
+ - \`productAttributeOptions[].attribute.name\`, \`attributeOption.name\`
1564
1795
  - \`metafields[].value\`
1565
1796
 
1566
1797
  **Taxonomy (\`getCategories\`, \`getBrands\`, \`getTags\`):**
@@ -1569,6 +1800,10 @@ overlay needed:
1569
1800
  **Cart:**
1570
1801
  - \`items[].product.name\`, \`items[].variant.name\`
1571
1802
 
1803
+ **Bundles & Order Bumps:**
1804
+ - \`bundleProduct.name\`, \`bundleProduct.slug\`, \`bumpProduct.name\`, \`bumpProduct.slug\`
1805
+ - Variant names inside bundles/bumps
1806
+
1572
1807
  **Checkout:**
1573
1808
  - Line items: \`items[].product.name\`, \`items[].variant.name\`
1574
1809
  - Discount banners, nudges, badges
@@ -1653,6 +1888,265 @@ const admin = new BrainerceClient({ apiKey: 'brainerce_...' });
1653
1888
  // OAuth: getOAuthProviders(), configureOAuthProvider()
1654
1889
  \`\`\``;
1655
1890
  }
1891
+ function getContactInquiriesSection() {
1892
+ return `## Contact Inquiries & Forms (optional)
1893
+
1894
+ Accept messages from one or more contact forms on your storefront. The merchant
1895
+ configures everything in the Brainerce dashboard under **Customers \u2192 Contact
1896
+ Forms**; submissions land in **Customers \u2192 Inquiries** and both sides receive
1897
+ email notifications.
1898
+
1899
+ **IMPORTANT for AI code generators:** the merchant OWNS the form shape. Do not
1900
+ hardcode labels, placeholders, help text, the submit button, or the success
1901
+ message. Render from the schema returned by \`contactForms.get()\` so the
1902
+ storefront reflects whatever the merchant configures \u2014 including new fields
1903
+ they add later, translations they add later, and the success message they
1904
+ write. This is the difference between "a contact form" and "THE merchant's
1905
+ contact form."
1906
+
1907
+ ### Two integration paths
1908
+
1909
+ 1. **Simple (legacy)** \u2014 pass \`{ name, email, subject, message }\` and submit.
1910
+ Works out of the box, no dashboard setup needed. Backed by a lazily-seeded
1911
+ default form keyed \`"main"\` with five built-in fields (\`name\`, \`email\`,
1912
+ \`phone\`, \`subject\`, \`message\`). Choose this only for throwaway demos.
1913
+ 2. **Flexible (recommended, default in \`npx create-brainerce-store\`)** \u2014
1914
+ merchants can configure multiple forms per store (e.g. \`main\`,
1915
+ \`newsletter\`, \`whatsapp_prechat\`), add custom fields (TEXT, TEXTAREA,
1916
+ EMAIL, PHONE, NUMBER, SELECT, MULTI_SELECT, CHECKBOX, URL, DATE), translate
1917
+ everything per locale, hide any non-essential built-in. Fetch the schema
1918
+ with \`contactForms.get(formKey, locale)\` and render dynamically.
1919
+
1920
+ ### Simple path (legacy shape)
1921
+
1922
+ \`\`\`typescript
1923
+ import type { CreateInquiryInput, CreateInquiryResponse } from 'brainerce';
1924
+
1925
+ const result = await brainerce.createInquiry({
1926
+ name: 'Jane Doe',
1927
+ email: 'jane@example.com',
1928
+ subject: 'Question about shipping',
1929
+ message: 'Hi, do you ship internationally?',
1930
+ phone: '+1-555-0100', // optional
1931
+ customerId: customer?.id, // optional \u2014 link to logged-in customer
1932
+ metadata: { page: '/contact' }, // optional
1933
+ });
1934
+ // \u2192 { id, status: 'NEW', createdAt }
1935
+ \`\`\`
1936
+
1937
+ ### Flexible path \u2014 end-to-end contract
1938
+
1939
+ **Endpoints:**
1940
+
1941
+ | Method | Path | Returns |
1942
+ | ------ | ----------------------------------------------------------------- | ------------------------------ |
1943
+ | GET | \`/stores/:storeId/contact-forms\` | \`ContactFormSummary[]\` |
1944
+ | GET | \`/stores/:storeId/contact-forms/:formKey?locale=xx\` | \`ContactFormPublic\` |
1945
+ | POST | \`/stores/:storeId/inquiries\` | \`CreateInquiryResponse\` |
1946
+
1947
+ All three are public (no API key). The SDK wraps them so you never call them
1948
+ directly \u2014 use \`brainerce.contactForms.list()\`, \`brainerce.contactForms.get()\`,
1949
+ and \`brainerce.createInquiry()\`.
1950
+
1951
+ \`\`\`typescript
1952
+ import type { ContactFormPublic } from 'brainerce';
1953
+
1954
+ // List active forms (optional \u2014 if you want a form picker or route-per-form setup)
1955
+ const forms = await brainerce.contactForms.list();
1956
+ // \u2192 [{ key: 'main', name: 'Contact us', isDefault: true }, ...]
1957
+
1958
+ // Fetch one form's schema \u2014 server pre-resolves translations for the locale
1959
+ // and strips every field the merchant marked isVisible=false.
1960
+ const form = await brainerce.contactForms.get('main', 'en');
1961
+ // \u2192 {
1962
+ // id, key, name, description?, submitButton, successMessage,
1963
+ // fields: [{ key, type, label, placeholder?, helpText?, isRequired,
1964
+ // enumValues?, validation?, defaultValue? }, ...]
1965
+ // }
1966
+
1967
+ // Submit a keyed payload. Unknown keys are stripped, required fields are
1968
+ // enforced, and every value is validated against \`validation\` server-side.
1969
+ await brainerce.createInquiry({
1970
+ formKey: 'main',
1971
+ fields: {
1972
+ email: 'jane@example.com', // built-in keys
1973
+ message: 'Hi...',
1974
+ // ...any custom keys the merchant added via the dashboard
1975
+ },
1976
+ locale: 'en', // stored on the inquiry \u2014 lets staff filter by language
1977
+ sourceMetadata: { page: '/contact' }, // arbitrary provenance (UTM, referrer, etc.)
1978
+ });
1979
+ \`\`\`
1980
+
1981
+ Both shapes go to the same POST endpoint and may be mixed; \`fields\` wins when
1982
+ both provide the same key. Unknown keys (not defined on the form schema) are
1983
+ stripped server-side.
1984
+
1985
+ ### Dynamic rendering \u2014 reference implementation
1986
+
1987
+ Render every field type the merchant can pick. Keep this as a \`<DynamicField>\`
1988
+ component so the form is fully driven by \`schema.fields\`.
1989
+
1990
+ \`\`\`tsx
1991
+ import type { ContactFormPublic, ContactFormPublicField } from 'brainerce';
1992
+
1993
+ type FieldValue = string | string[] | boolean;
1994
+
1995
+ function defaultValueFor(f: ContactFormPublicField): FieldValue {
1996
+ if (f.type === 'CHECKBOX') return false;
1997
+ if (f.type === 'MULTI_SELECT') return [];
1998
+ return f.defaultValue ?? '';
1999
+ }
2000
+
2001
+ function isEmpty(v: FieldValue): boolean {
2002
+ if (typeof v === 'string') return v.trim().length === 0;
2003
+ if (Array.isArray(v)) return v.length === 0;
2004
+ return v === false;
2005
+ }
2006
+
2007
+ function DynamicField({
2008
+ field,
2009
+ value,
2010
+ onChange,
2011
+ }: {
2012
+ field: ContactFormPublicField;
2013
+ value: FieldValue;
2014
+ onChange: (v: FieldValue) => void;
2015
+ }) {
2016
+ const id = \`contact-\${field.key}\`;
2017
+ const { minLength, maxLength, min, max, pattern } = field.validation ?? {};
2018
+ const strVal = typeof value === 'string' ? value : '';
2019
+
2020
+ // Label \u2014 USE \`field.label\`, NEVER hardcode. Falls back to the key.
2021
+ const label = (
2022
+ <label htmlFor={id} className="mb-1.5 block text-sm font-medium">
2023
+ {field.label}
2024
+ {field.isRequired && <span aria-hidden className="text-red-500"> *</span>}
2025
+ </label>
2026
+ );
2027
+ const help = field.helpText ? <p className="mt-1 text-xs opacity-70">{field.helpText}</p> : null;
2028
+
2029
+ switch (field.type) {
2030
+ case 'TEXTAREA':
2031
+ return (<div>{label}<textarea id={id} required={field.isRequired} maxLength={maxLength} minLength={minLength} rows={6} placeholder={field.placeholder} value={strVal} onChange={(e) => onChange(e.target.value)} />{help}</div>);
2032
+ case 'EMAIL':
2033
+ return (<div>{label}<input id={id} type="email" required={field.isRequired} autoComplete="email" placeholder={field.placeholder} value={strVal} onChange={(e) => onChange(e.target.value)} />{help}</div>);
2034
+ case 'PHONE':
2035
+ return (<div>{label}<input id={id} type="tel" required={field.isRequired} autoComplete="tel" placeholder={field.placeholder} value={strVal} onChange={(e) => onChange(e.target.value)} />{help}</div>);
2036
+ case 'URL':
2037
+ return (<div>{label}<input id={id} type="url" required={field.isRequired} placeholder={field.placeholder} value={strVal} onChange={(e) => onChange(e.target.value)} />{help}</div>);
2038
+ case 'NUMBER':
2039
+ return (<div>{label}<input id={id} type="number" required={field.isRequired} min={min} max={max} placeholder={field.placeholder} value={strVal} onChange={(e) => onChange(e.target.value)} />{help}</div>);
2040
+ case 'DATE':
2041
+ return (<div>{label}<input id={id} type="date" required={field.isRequired} value={strVal} onChange={(e) => onChange(e.target.value)} />{help}</div>);
2042
+ case 'SELECT':
2043
+ return (<div>{label}<select id={id} required={field.isRequired} value={strVal} onChange={(e) => onChange(e.target.value)}><option value="">\u2014</option>{field.enumValues?.map((o) => (<option key={o.value} value={o.value}>{o.label}</option>))}</select>{help}</div>);
2044
+ case 'MULTI_SELECT': {
2045
+ const arr = Array.isArray(value) ? value : [];
2046
+ return (<div>{label}<div>{field.enumValues?.map((o) => { const checked = arr.includes(o.value); return (<label key={o.value}><input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked ? [...arr, o.value] : arr.filter((v) => v !== o.value))} /><span>{o.label}</span></label>); })}</div>{help}</div>);
2047
+ }
2048
+ case 'CHECKBOX':
2049
+ return (<div><label htmlFor={id}><input id={id} type="checkbox" required={field.isRequired} checked={value === true} onChange={(e) => onChange(e.target.checked)} /><span>{field.label}</span></label>{help}</div>);
2050
+ case 'TEXT':
2051
+ default:
2052
+ return (<div>{label}<input id={id} type="text" required={field.isRequired} maxLength={maxLength} minLength={minLength} pattern={pattern} placeholder={field.placeholder} value={strVal} onChange={(e) => onChange(e.target.value)} />{help}</div>);
2053
+ }
2054
+ }
2055
+
2056
+ export function ContactPage() {
2057
+ const [schema, setSchema] = useState<ContactFormPublic | null>(null);
2058
+ const [values, setValues] = useState<Record<string, FieldValue>>({});
2059
+ const [honeypot, setHoneypot] = useState('');
2060
+ const [sent, setSent] = useState(false);
2061
+ const [loading, setLoading] = useState(false);
2062
+
2063
+ // Read the current locale from the <html lang> attribute (or your own i18n context).
2064
+ const locale = typeof document !== 'undefined' ? document.documentElement.lang || undefined : undefined;
2065
+
2066
+ useEffect(() => {
2067
+ brainerce.contactForms.get('main', locale).then((form) => {
2068
+ setSchema(form);
2069
+ const initial: Record<string, FieldValue> = {};
2070
+ for (const f of form.fields) initial[f.key] = defaultValueFor(f);
2071
+ setValues(initial);
2072
+ });
2073
+ }, [locale]);
2074
+
2075
+ if (!schema) return null;
2076
+
2077
+ const onSubmit = async (e: React.FormEvent) => {
2078
+ e.preventDefault();
2079
+ if (loading) return;
2080
+ if (honeypot.trim().length > 0) { setSent(true); return; } // bot \u2192 silent success
2081
+
2082
+ setLoading(true);
2083
+ try {
2084
+ const payload: Record<string, unknown> = {};
2085
+ for (const f of schema.fields) {
2086
+ const raw = values[f.key];
2087
+ if (isEmpty(raw)) continue;
2088
+ payload[f.key] = typeof raw === 'string' ? raw.trim() : raw;
2089
+ }
2090
+ await brainerce.createInquiry({ formKey: schema.key, fields: payload, locale });
2091
+ setSent(true);
2092
+ } finally {
2093
+ setLoading(false);
2094
+ }
2095
+ };
2096
+
2097
+ if (sent) {
2098
+ // RENDER THE MERCHANT'S success message \u2014 do not hardcode.
2099
+ return <div>{schema.successMessage}</div>;
2100
+ }
2101
+
2102
+ return (
2103
+ <form onSubmit={onSubmit} noValidate>
2104
+ <h1>{schema.name}</h1>
2105
+ {schema.description && <p>{schema.description}</p>}
2106
+
2107
+ {/* Honeypot \u2014 must be visually hidden from humans, not from bots */}
2108
+ <div aria-hidden style={{ position: 'absolute', left: '-10000px', width: 0, height: 0, overflow: 'hidden' }}>
2109
+ <label htmlFor="contact-honeypot">Leave this field empty</label>
2110
+ <input id="contact-honeypot" type="text" tabIndex={-1} autoComplete="off"
2111
+ value={honeypot} onChange={(e) => setHoneypot(e.target.value)} />
2112
+ </div>
2113
+
2114
+ {schema.fields.map((field) => (
2115
+ <DynamicField key={field.key} field={field}
2116
+ value={values[field.key] ?? defaultValueFor(field)}
2117
+ onChange={(v) => setValues((p) => ({ ...p, [field.key]: v }))} />
2118
+ ))}
2119
+
2120
+ <button type="submit" disabled={loading}>
2121
+ {loading ? '\u2026' : schema.submitButton}
2122
+ </button>
2123
+ </form>
2124
+ );
2125
+ }
2126
+ \`\`\`
2127
+
2128
+ ### Rules
2129
+
2130
+ - **Rate limit:** 3 submissions / 60s per IP \u2014 show a friendly "try again later" message on HTTP 429.
2131
+ - **Honeypot:** always render a hidden field named \`honeypot\` and **never send** it. Bots fill every input; the server rejects submissions carrying a non-empty \`honeypot\`.
2132
+ - **Built-in keys:** \`name\`, \`email\`, \`phone\`, \`subject\`, \`message\` always exist on the default form; the legacy \`createInquiry\` shape keeps working forever.
2133
+ - **Max values:** each field value is capped at 10 000 chars server-side. Validate client-side before submitting using \`field.validation.maxLength\`.
2134
+ - **Required fields:** respect \`field.isRequired\`. The server validates too, but show \`required\` on the input for browser-level UX.
2135
+ - **Validation:** when \`field.validation.pattern\` is present, pass it as the input's \`pattern\` attribute; the server also enforces it. Likewise \`minLength\`/\`maxLength\`/\`min\`/\`max\`.
2136
+ - **Enum values:** SELECT and MULTI_SELECT always return \`enumValues\` (non-empty array). Render from \`enumValues\`, never a hardcoded list.
2137
+ - **Visibility:** the server strips fields with \`isVisible=false\`, so \`schema.fields\` only contains things to render.
2138
+ - **Origin check:** the endpoint validates \`Origin\` against the store's allowed origins. Test from your real storefront URL, not \`file://\` or localhost unless the merchant added it to allowed origins.
2139
+ - **Locale handling:** always pass \`locale\` to \`contactForms.get()\` and to \`createInquiry()\`. The staff inbox filters inquiries by language. Omit to fall back to the store's default language.
2140
+ - **Success message:** render \`schema.successMessage\` as-is. (Variable interpolation like \`{{customerName}}\` is not yet wired; any such placeholders currently render literally. Safe to use plain text.)
2141
+ - **Caching:** the schema GET is safe to cache per \`{storeId, formKey, locale}\`. 60 s feels live without hammering the API.
2142
+
2143
+ ### Multiple forms
2144
+
2145
+ If the merchant set up more than one form (e.g. a \`main\` form on \`/contact\`
2146
+ and a \`newsletter\` form embedded in the footer), render each form from its
2147
+ own \`contactForms.get(key, locale)\` call. The \`formKey\` you pass to
2148
+ \`createInquiry\` must match.`;
2149
+ }
1656
2150
  function getSectionByTopic(topic, connectionId, currency) {
1657
2151
  const cid = connectionId || "vc_YOUR_CONNECTION_ID";
1658
2152
  const cur = currency || "USD";
@@ -1679,6 +2173,8 @@ function getSectionByTopic(topic, connectionId, currency) {
1679
2173
  return getDiscountsSection();
1680
2174
  case "recommendations":
1681
2175
  return getRecommendationsSection();
2176
+ case "product-customization-fields":
2177
+ return getProductCustomizationFieldsSection();
1682
2178
  case "tax":
1683
2179
  return getTaxDisplaySection(cur);
1684
2180
  case "i18n":
@@ -1689,6 +2185,8 @@ function getSectionByTopic(topic, connectionId, currency) {
1689
2185
  return getTypeQuickReference();
1690
2186
  case "admin":
1691
2187
  return getAdminApiSection();
2188
+ case "inquiries":
2189
+ return getContactInquiriesSection();
1692
2190
  case "all":
1693
2191
  return [
1694
2192
  "# Brainerce SDK \u2014 full topic dump",
@@ -1753,6 +2251,10 @@ function getSectionByTopic(topic, connectionId, currency) {
1753
2251
  "",
1754
2252
  "---",
1755
2253
  "",
2254
+ getProductCustomizationFieldsSection(),
2255
+ "",
2256
+ "---",
2257
+ "",
1756
2258
  getTaxDisplaySection(cur),
1757
2259
  "",
1758
2260
  "---",
@@ -1761,10 +2263,14 @@ function getSectionByTopic(topic, connectionId, currency) {
1761
2263
  "",
1762
2264
  "---",
1763
2265
  "",
1764
- getAdminApiSection()
2266
+ getAdminApiSection(),
2267
+ "",
2268
+ "---",
2269
+ "",
2270
+ getContactInquiriesSection()
1765
2271
  ].join("\n");
1766
2272
  default:
1767
- return `Unknown topic: "${topic}". Available topics: setup, products, cart, checkout, checkout-custom-fields, payment, auth, order-confirmation, inventory, discounts, recommendations, tax, i18n, critical-rules, type-reference, admin, all`;
2273
+ return `Unknown topic: "${topic}". Available topics: setup, products, cart, checkout, checkout-custom-fields, payment, auth, order-confirmation, inventory, discounts, recommendations, product-customization-fields, tax, i18n, critical-rules, type-reference, admin, inquiries, all`;
1768
2274
  }
1769
2275
  }
1770
2276
 
@@ -1784,11 +2290,13 @@ var GET_SDK_DOCS_SCHEMA = {
1784
2290
  "inventory",
1785
2291
  "discounts",
1786
2292
  "recommendations",
2293
+ "product-customization-fields",
1787
2294
  "tax",
1788
2295
  "critical-rules",
1789
2296
  "type-reference",
1790
2297
  "i18n",
1791
2298
  "admin",
2299
+ "inquiries",
1792
2300
  "all"
1793
2301
  ]).describe("The SDK documentation topic to retrieve"),
1794
2302
  connectionId: import_zod.z.string().optional().describe("Vibe-coded connection ID (starts with vc_). Used to personalize setup code."),
@@ -1828,6 +2336,7 @@ interface Product {
1828
2336
  brands?: Array<{ id: string; name: string }>;
1829
2337
  tags?: string[];
1830
2338
  metafields?: ProductMetafield[];
2339
+ customizationFields?: ProductCustomizationField[]; // Buyer input fields \u2014 render on PDP, send values in cart metadata. Account-wide fields (MetafieldDefinition.appliesToAllProducts=true) are folded in here automatically; no client-side merging.
1831
2340
  productAttributeOptions?: Array<{
1832
2341
  id: string;
1833
2342
  attributeId: string;
@@ -1886,6 +2395,32 @@ interface ProductMetafield {
1886
2395
  variantId?: string | null;
1887
2396
  }
1888
2397
 
2398
+ // Metafield type enum \u2014 used by ProductCustomizationField.type
2399
+ type MetafieldType =
2400
+ | 'TEXT' | 'TEXTAREA' | 'NUMBER' | 'BOOLEAN' | 'DATE' | 'DATETIME'
2401
+ | 'URL' | 'COLOR' | 'DIMENSION' | 'WEIGHT' | 'JSON'
2402
+ | 'IMAGE' | 'GALLERY'
2403
+ | 'SELECT' | 'MULTI_SELECT';
2404
+
2405
+ // Customer-facing input assigned per product. Render on the PDP in the order of \`position\`.
2406
+ // Submit values keyed by \`key\` inside AddToCartDto.metadata.
2407
+ // Upload files via \`uploadCustomizationFile()\` first, then place the returned \`url\` in metadata.
2408
+ interface ProductCustomizationField {
2409
+ definitionId: string;
2410
+ name: string; // Label to show buyers
2411
+ key: string; // Use as metadata key in AddToCartDto.metadata
2412
+ description?: string | null; // Help text
2413
+ type: MetafieldType;
2414
+ required: boolean;
2415
+ minLength?: number | null; // TEXT/TEXTAREA char bounds; MULTI_SELECT array bounds
2416
+ maxLength?: number | null;
2417
+ minValue?: number | null; // NUMBER bounds
2418
+ maxValue?: number | null;
2419
+ enumValues?: string[]; // REQUIRED for SELECT / MULTI_SELECT
2420
+ defaultValue?: string | null;
2421
+ position: number; // Render order
2422
+ }
2423
+
1889
2424
  interface ProductQueryParams {
1890
2425
  page?: number;
1891
2426
  limit?: number;
@@ -1969,6 +2504,7 @@ interface CartItem {
1969
2504
  sku?: string | null;
1970
2505
  image?: ProductImage | string | null;
1971
2506
  } | null;
2507
+ metadata?: Record<string, unknown> | null; // Buyer-submitted customization values, keyed by ProductCustomizationField.key
1972
2508
  createdAt: string;
1973
2509
  updatedAt: string;
1974
2510
  }
@@ -2033,6 +2569,9 @@ interface Checkout {
2033
2569
  taxAmount: string;
2034
2570
  taxBreakdown?: TaxBreakdown | null;
2035
2571
  total: string;
2572
+ surchargeAmount: string;
2573
+ appliedSurcharges?: Array<{ key: string; name: string; value: unknown; amount: string }> | null;
2574
+ customFieldValues?: Record<string, unknown> | null;
2036
2575
  couponCode?: string | null;
2037
2576
  lineItems: CheckoutLineItem[]; // Use THIS for order summary, NOT cart.items!
2038
2577
  itemCount: number;
@@ -2051,6 +2590,7 @@ interface CheckoutLineItem {
2051
2590
  discountAmount: string;
2052
2591
  product: { id: string; name: string; sku: string; images?: ProductImage[]; };
2053
2592
  variant?: { id: string; name?: string | null; sku?: string | null; image?: ProductImage | string | null; } | null;
2593
+ metadata?: Record<string, unknown> | null; // Copied from CartItem.metadata \u2014 buyer customization values
2054
2594
  }
2055
2595
 
2056
2596
  interface CheckoutAddress {
@@ -2132,6 +2672,27 @@ interface Order {
2132
2672
  billingAddress?: OrderAddress;
2133
2673
  hasDownloads?: boolean;
2134
2674
  createdAt: string;
2675
+
2676
+ // Payment + fulfillment
2677
+ paymentMethod?: string | null;
2678
+ financialStatus?: string | null; // "pending" | "paid" | "refunded" | "partially_refunded" | "voided"
2679
+ fulfillmentStatus?: string | null; // "unfulfilled" | "partial" | "fulfilled"
2680
+
2681
+ // Tracking
2682
+ trackingNumber?: string | null;
2683
+ trackingUrl?: string | null;
2684
+ carrier?: string | null;
2685
+ shippedAt?: string | null; // ISO-8601
2686
+ deliveredAt?: string | null; // ISO-8601
2687
+
2688
+ // Timeline of status transitions, chronological
2689
+ statusHistory?: OrderStatusChange[] | null;
2690
+ }
2691
+
2692
+ interface OrderStatusChange {
2693
+ status: OrderStatus;
2694
+ at: string; // ISO-8601
2695
+ note?: string | null;
2135
2696
  }
2136
2697
 
2137
2698
  // \u26A0\uFE0F OrderItem is FLAT \u2014 unlike CartItem which is NESTED
@@ -2145,6 +2706,9 @@ interface OrderItem {
2145
2706
  unitPrice?: string; // alias
2146
2707
  totalPrice?: string;
2147
2708
  image?: string; // FLAT: item.image (NOT nested)
2709
+ // Snapshot of buyer-submitted customization values captured at checkout.
2710
+ // Keyed by metafield slug. \`value\` is string[] for MULTI_SELECT / GALLERY, string otherwise.
2711
+ customizations?: Record<string, { label: string; value: string | string[]; type: string }>;
2148
2712
  }
2149
2713
 
2150
2714
  interface OrderCustomer {
@@ -2366,7 +2930,129 @@ interface ProductRecommendationsResponse {
2366
2930
  }
2367
2931
  interface CartRecommendationsResponse {
2368
2932
  recommendations: ProductRecommendation[];
2933
+ }
2934
+
2935
+ // Cart include types (for consolidated getCart requests)
2936
+ type CartIncludeOption = 'recommendations' | 'upgrades' | 'bundles';
2937
+ interface CartIncludeOptions {
2938
+ include?: CartIncludeOption[];
2939
+ }
2940
+ interface CartWithIncludes extends Cart {
2941
+ recommendations?: { recommendations: ProductRecommendation[] };
2942
+ upgrades?: { upgrades: Record<string, CartUpgradeSuggestion> };
2943
+ bundles?: { bundles: CartBundleOffer[] };
2944
+ }
2945
+ interface CartUpgradeSuggestion {
2946
+ targetProduct: ProductRecommendation;
2947
+ priceDelta: string;
2948
+ deltaPercent: number;
2949
+ }
2950
+ interface CartBundleOffer {
2951
+ id: string;
2952
+ bundleProduct: ProductRecommendation;
2953
+ originalPrice: string;
2954
+ discountedPrice: string;
2955
+ discountType: 'PERCENTAGE' | 'FIXED_AMOUNT';
2956
+ discountValue: number;
2957
+ requiresVariantSelection: boolean;
2958
+ lockedVariant?: { id: string; name?: string };
2369
2959
  }`;
2960
+ var INQUIRIES_TYPES = `// ---- Contact Inquiries & Forms ----
2961
+
2962
+ // Two shapes \u2014 legacy and flexible. The two may be mixed; \`fields\` wins on key collision.
2963
+ interface CreateInquiryInput {
2964
+ // Legacy shape (still supported forever, backed by default "main" form)
2965
+ name?: string; // max 120 chars (if provided)
2966
+ email?: string; // must be valid email if provided
2967
+ subject?: string; // max 200 chars
2968
+ message?: string; // max 10000 chars
2969
+ phone?: string;
2970
+
2971
+ // Flexible shape
2972
+ formKey?: string; // defaults to "main"; merchant-defined keys e.g. "newsletter"
2973
+ fields?: Record<string, unknown>; // bag of values keyed by field key (built-in or custom)
2974
+ locale?: string; // e.g. "en", "he" \u2014 stored on the inquiry
2975
+ sourceMetadata?: Record<string, unknown>; // arbitrary context (e.g. { page: '/contact', campaign: 'fall-2026' })
2976
+
2977
+ // Shared
2978
+ customerId?: string; // link to logged-in customer
2979
+ metadata?: Record<string, unknown>; // deprecated alias of sourceMetadata
2980
+ }
2981
+
2982
+ interface CreateInquiryResponse {
2983
+ id: string;
2984
+ status: 'NEW';
2985
+ createdAt: string; // ISO datetime
2986
+ }
2987
+
2988
+ // ---- Form schema (for dynamic rendering) ----
2989
+
2990
+ type ContactFormFieldType =
2991
+ | 'TEXT' | 'TEXTAREA' | 'EMAIL' | 'PHONE' | 'NUMBER'
2992
+ | 'SELECT' | 'MULTI_SELECT' | 'CHECKBOX' | 'URL' | 'DATE';
2993
+
2994
+ interface ContactFormFieldValidation {
2995
+ minLength?: number;
2996
+ maxLength?: number;
2997
+ min?: number;
2998
+ max?: number;
2999
+ pattern?: string; // regex string
3000
+ patternMessage?: string;
3001
+ }
3002
+
3003
+ interface ContactFormPublicField {
3004
+ key: string; // stable identifier, e.g. "email", "company"
3005
+ type: ContactFormFieldType;
3006
+ label: string; // already localized for the requested locale
3007
+ placeholder?: string; // already localized
3008
+ helpText?: string; // already localized
3009
+ isRequired: boolean;
3010
+ enumValues?: { value: string; label: string }[]; // present (non-empty) for SELECT / MULTI_SELECT
3011
+ validation?: ContactFormFieldValidation;
3012
+ defaultValue?: string;
3013
+ }
3014
+
3015
+ interface ContactFormPublic {
3016
+ id: string;
3017
+ key: string; // e.g. "main", "newsletter"
3018
+ name: string; // already localized \u2014 use as form heading
3019
+ description?: string; // already localized \u2014 use as subtitle
3020
+ submitButton: string; // already localized \u2014 use as submit label
3021
+ successMessage: string; // already localized \u2014 render after submit succeeds
3022
+ fields: ContactFormPublicField[]; // in display order; hidden fields already filtered out
3023
+ }
3024
+
3025
+ interface ContactFormSummary {
3026
+ key: string;
3027
+ name: string;
3028
+ isDefault: boolean;
3029
+ }
3030
+
3031
+ // Field-type \u2192 HTML mapping for dynamic rendering
3032
+ // ------------------------------------------------------------------
3033
+ // TEXT \u2192 <input type="text" ... pattern?={validation.pattern}>
3034
+ // TEXTAREA \u2192 <textarea rows={6} ...>
3035
+ // EMAIL \u2192 <input type="email" autoComplete="email" ...>
3036
+ // PHONE \u2192 <input type="tel" autoComplete="tel" ...>
3037
+ // NUMBER \u2192 <input type="number" min={validation.min} max={validation.max}>
3038
+ // URL \u2192 <input type="url" ...>
3039
+ // DATE \u2192 <input type="date" ...> (value is ISO yyyy-MM-dd)
3040
+ // SELECT \u2192 <select>{enumValues.map(...)}</select> \u2014 always rendered from enumValues
3041
+ // MULTI_SELECT \u2192 multiple <input type="checkbox">, value is string[]
3042
+ // CHECKBOX \u2192 single <input type="checkbox">, value is boolean
3043
+ // ------------------------------------------------------------------
3044
+
3045
+ // SDK methods
3046
+ // await brainerce.createInquiry(input) \u2192 POST /stores/{storeId}/inquiries
3047
+ // await brainerce.contactForms.list() \u2192 GET /stores/{storeId}/contact-forms
3048
+ // await brainerce.contactForms.get(key?, locale?) \u2192 GET /stores/{storeId}/contact-forms/{key}?locale={locale}
3049
+ //
3050
+ // Rules
3051
+ // - Rate limit: 3 submissions / 60s per IP \u2014 handle 429 responses gracefully
3052
+ // - Honeypot: always render an invisible field named \`honeypot\` and never send it
3053
+ // - Always pass \`locale\` \u2014 inbox filters inquiries by language, and schema labels come back translated
3054
+ // - Render \`schema.name\` / \`description\` / \`submitButton\` / \`successMessage\` directly \u2014 do NOT hardcode copy
3055
+ // - Unknown field keys are stripped server-side \u2014 safe to send extras during dev`;
2370
3056
  var TYPES_BY_DOMAIN = {
2371
3057
  products: PRODUCTS_TYPES,
2372
3058
  cart: CART_TYPES,
@@ -2374,7 +3060,8 @@ var TYPES_BY_DOMAIN = {
2374
3060
  orders: ORDERS_TYPES,
2375
3061
  customers: CUSTOMERS_TYPES,
2376
3062
  payments: PAYMENTS_TYPES,
2377
- helpers: HELPERS_TYPES
3063
+ helpers: HELPERS_TYPES,
3064
+ inquiries: INQUIRIES_TYPES
2378
3065
  };
2379
3066
  function getTypesByDomain(domain) {
2380
3067
  if (domain === "all") {
@@ -2395,7 +3082,17 @@ var AVAILABLE_DOMAINS = Object.keys(TYPES_BY_DOMAIN);
2395
3082
  var GET_TYPE_DEFINITIONS_NAME = "get-type-definitions";
2396
3083
  var GET_TYPE_DEFINITIONS_DESCRIPTION = "Get TypeScript type definitions from the Brainerce SDK, segmented by domain. Use this when you need to understand the exact shape of objects like Product, Cart, Checkout, Order, etc.";
2397
3084
  var GET_TYPE_DEFINITIONS_SCHEMA = {
2398
- domain: import_zod2.z.enum(["products", "cart", "checkout", "orders", "customers", "payments", "helpers", "all"]).describe(
3085
+ domain: import_zod2.z.enum([
3086
+ "products",
3087
+ "cart",
3088
+ "checkout",
3089
+ "orders",
3090
+ "customers",
3091
+ "payments",
3092
+ "helpers",
3093
+ "inquiries",
3094
+ "all"
3095
+ ]).describe(
2399
3096
  'The domain of types to retrieve. Use "helpers" for helper function signatures and common types like StoreInfo, PaginatedResponse.'
2400
3097
  )
2401
3098
  };
@@ -2429,7 +3126,8 @@ var GET_CODE_EXAMPLE_SCHEMA = {
2429
3126
  "coupon-apply-and-remove",
2430
3127
  "reservation-countdown",
2431
3128
  "search-autocomplete-debounce",
2432
- "i18n-set-locale"
3129
+ "i18n-set-locale",
3130
+ "order-history-full"
2433
3131
  ]).describe("The SDK operation to get a snippet for.")
2434
3132
  };
2435
3133
  var SNIPPETS = {
@@ -2524,23 +3222,51 @@ const checkout = await client.getCheckout(checkoutId);
2524
3222
  "checkout-payment-providers": `// Fetch configured payment providers for this checkout.
2525
3223
  import { client } from './brainerce';
2526
3224
 
2527
- const providers = await client.getPaymentProviders(checkoutId);
2528
- // providers = [{ provider, name, renderType, config }, ...]
2529
- // renderType tells you how to render:
2530
- // 'stripe-elements' \u2014 render Stripe Elements form and call stripe.confirmCardPayment
2531
- // 'redirect' \u2014 render a button that navigates to provider.authorizationUrl
2532
- // 'paypal' \u2014 render PayPal SDK button
2533
- // 'sandbox' \u2014 render a "complete test order" button (no real charge)
2534
-
2535
- // Pick the first configured provider by default (or let the user pick if
2536
- // multiple are configured).
2537
- const active = providers[0];
3225
+ const { providers, hasPayments, defaultProvider } = await client.getPaymentProviders();
3226
+ // each provider: { id, provider, name, publicKey, supportedMethods, testMode, isDefault, clientSdk? }
3227
+ //
3228
+ // After createPaymentIntent, the response carries a clientSdk.renderType that
3229
+ // tells your UI how to render the payment step. The 5 possible values:
3230
+ //
3231
+ // 'sdk-widget' \u2014 Load the provider's JS SDK (scriptUrl), then mount
3232
+ // into a <div id={containerId}>. Used by Stripe,
3233
+ // PayPal, Grow. The SDK paints its own form inside
3234
+ // your DOM. You control layout/placement.
3235
+ //
3236
+ // 'iframe' \u2014 Render <iframe src={clientSecret}>. Two flavors:
3237
+ // a) URL path contains "/embed/" \u2192 Brainerce-hosted
3238
+ // embed page. Render INLINE in your checkout
3239
+ // flow. Listen for postMessage:
3240
+ // - 'brainerce:resize' \u2192 update iframe height
3241
+ // - 'brainerce:redirect' \u2192 validated top-level
3242
+ // navigation (e.g. Bit express-pay)
3243
+ // b) Any other URL \u2192 provider-hosted page. Render
3244
+ // inside a modal overlay (it carries its own
3245
+ // branding/chrome).
3246
+ //
3247
+ // 'redirect' \u2014 Full-page navigate: window.location.href = URL.
3248
+ // Customer leaves your site, pays on provider page,
3249
+ // returns via SuccessRedirectUrl.
3250
+ //
3251
+ // 'sandbox' \u2014 Test mode. Render a "Complete Test Order" button;
3252
+ // call completeGuestCheckout(checkoutId). Orders are
3253
+ // flagged isTestOrder: true. No real charge.
3254
+ //
3255
+ // 'embedded-fields' \u2014 (Reserved \u2014 not yet shipped in any provider.) PCI
3256
+ // micro-iframes for sensitive fields (card number,
3257
+ // CVV) mounted directly into the merchant form. Full
3258
+ // control of surrounding layout, like Stripe
3259
+ // Elements. Requires the provider to ship a client
3260
+ // SDK that exposes mount points.
3261
+ //
3262
+ // Always branch on clientSdk.renderType \u2014 NEVER hard-code by provider name.
3263
+ // New providers can be added without storefront changes.
2538
3264
 
2539
- // If providers is empty, NO payment provider is configured and customers
2540
- // cannot pay. Display a clear error \u2014 do not try to fake a checkout.
2541
3265
  if (providers.length === 0) {
2542
3266
  throw new Error('No payment provider configured for this store');
2543
- }`,
3267
+ }
3268
+
3269
+ const active = providers[0];`,
2544
3270
  "checkout-stripe-confirm": `// Stripe confirm flow. Requires @stripe/stripe-js and @stripe/react-stripe-js
2545
3271
  // (or the vanilla Stripe.js browser SDK).
2546
3272
  import { loadStripe } from '@stripe/stripe-js';
@@ -2754,12 +3480,82 @@ const locale = 'he'; // e.g., from the /[locale] route segment
2754
3480
  client.setLocale(locale);
2755
3481
 
2756
3482
  // ALL content comes back translated: products, categories, brands,
2757
- // tags, variants, metafields, cart items, checkout line items,
2758
- // recommendations, search suggestions, discount banners, nudges,
3483
+ // tags, variants (name + attributes keys/values), metafields,
3484
+ // cart items, checkout line items, recommendations, bundles,
3485
+ // order bumps, search suggestions, discount banners, nudges,
2759
3486
  // badges, order history.
2760
3487
 
2761
3488
  // For RTL locales (he, ar), set the document direction.
2762
- // Framework-neutral: document.documentElement.setAttribute('dir', 'rtl');`
3489
+ // Framework-neutral: document.documentElement.setAttribute('dir', 'rtl');`,
3490
+ "order-history-full": `// Full "My Orders" account page. Render every piece of data the server
3491
+ // already returns \u2014 not just order number / total / status.
3492
+ //
3493
+ // Data you get from client.getMyOrders() (already on the wire today):
3494
+ // - items[].customizations \u2014 buyer's custom-field entries
3495
+ // - statusHistory \u2014 status timeline
3496
+ // - shippingAddress \u2014 name + address lines + country
3497
+ // - trackingNumber / trackingUrl / carrier / shippedAt / deliveredAt
3498
+ // - paymentMethod / financialStatus / fulfillmentStatus
3499
+ // - appliedDiscounts \u2014 shaped buyer-facing view
3500
+ // - hasDownloads \u2014 call /orders/:id/downloads to fetch
3501
+ //
3502
+ // Do NOT render: accountId, storeId, customerId, notes, customFieldValues
3503
+ // (merchant-internal, order-level), appliedRuleIds, appliedSurcharges,
3504
+ // surchargeAmount, downloadMeta (raw), pickupLocationData. The backend
3505
+ // does not return these to buyers \u2014 if you see them in a type, they're
3506
+ // an oversight; still skip them.
3507
+ import { client } from './brainerce';
3508
+ import type { Order, OrderItem, OrderStatusChange } from 'brainerce';
3509
+
3510
+ const { data: orders } = await client.getMyOrders({ page: 1, limit: 10 });
3511
+
3512
+ for (const order of orders) {
3513
+ // Line items + per-item customizations
3514
+ for (const item of order.items) {
3515
+ // item.image, item.productName, item.quantity, item.price
3516
+ if (item.customizations) {
3517
+ for (const [fieldId, entry] of Object.entries(item.customizations)) {
3518
+ // entry = { label, value, type }
3519
+ // Type-aware render (see account-page section in get-sdk-docs):
3520
+ // TEXT / TEXTAREA / URL / NUMBER / SELECT \u2014 plain text
3521
+ // BOOLEAN \u2014 \u2713 / \u2717
3522
+ // MULTI_SELECT \u2014 comma-separated
3523
+ // IMAGE \u2014 <img src={value}>
3524
+ // GALLERY \u2014 wrap grid of <img>
3525
+ // COLOR \u2014 swatch + hex
3526
+ // DATE / DATETIME \u2014 localized format
3527
+ }
3528
+ }
3529
+ }
3530
+
3531
+ // Status timeline \u2014 skip silently when null/empty
3532
+ if (order.statusHistory?.length) {
3533
+ for (const entry of order.statusHistory as OrderStatusChange[]) {
3534
+ // entry = { status, at, note? }
3535
+ // Render a dot + localized status label + new Date(entry.at).toLocaleString()
3536
+ }
3537
+ }
3538
+
3539
+ // Shipping + tracking
3540
+ if (order.shippingAddress) {
3541
+ // Render firstName lastName \xB7 line1 \xB7 line2 \xB7 city, region \xB7 postalCode \xB7 country
3542
+ }
3543
+ if (order.trackingNumber) {
3544
+ // Render carrier \xB7 trackingNumber, shippedAt/deliveredAt dates,
3545
+ // and an anchor to order.trackingUrl when present.
3546
+ }
3547
+
3548
+ // Payment
3549
+ if (order.paymentMethod || order.financialStatus) {
3550
+ // paymentMethod: 'card' | 'paypal' | 'bank_transfer' | 'cash_on_delivery' | ...
3551
+ // financialStatus: 'pending' | 'paid' | 'refunded' | 'partially_refunded' | 'voided'
3552
+ // Map financialStatus \u2192 a colored badge.
3553
+ }
3554
+ }
3555
+
3556
+ // All sections above are conditional. Absent data = section not rendered \u2014
3557
+ // never show empty placeholders. The create-brainerce-store template's
3558
+ // src/components/account/order-history.tsx is the reference implementation.`
2763
3559
  };
2764
3560
  async function handleGetCodeExample(args) {
2765
3561
  const snippet = SNIPPETS[args.operation];
@@ -3225,7 +4021,7 @@ var RULES = {
3225
4021
  - All prices are STRINGS in the SDK. \`parseFloat\` them before math or comparisons.
3226
4022
  - CartItem and CheckoutLineItem are NESTED (\`item.product.name\`, \`item.unitPrice\`). OrderItem is FLAT (\`item.name\`, \`item.price\`). They are not interchangeable.
3227
4023
  - Cart has no \`.total\` field \u2014 call \`getCartTotals(cart)\` to get \`{ subtotal, tax, shipping, discount, total }\`.
3228
- - \`smartGetCart()\` returns a discriminated union (\`Cart | LocalCart\`). Check \`'id' in cart\` before using server-only fields.
4024
+ - \`smartGetCart()\` returns \`CartWithIncludes\` (extends \`Cart\`). Pass \`{ include: ['recommendations', 'upgrades', 'bundles'] }\` to fetch extras in one request.
3229
4025
  - \`startGuestCheckout()\` is also a discriminated union. Check \`result.tracked\` before reading \`result.checkoutId\`.`
3230
4026
  }
3231
4027
  };
@@ -3263,6 +4059,7 @@ var GET_BUSINESS_FLOWS_SCHEMA = {
3263
4059
  "order-confirmation",
3264
4060
  "cart-persistence",
3265
4061
  "inventory-reservation",
4062
+ "product-customization",
3266
4063
  "all"
3267
4064
  ]).describe('Which flow to retrieve. Use "all" to get every flow.')
3268
4065
  };
@@ -3382,7 +4179,7 @@ Build the OAuth button region AND the callback handler even when no providers ar
3382
4179
  - **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).
3383
4180
  - **Writes:** use \`client.addToCart\`, \`client.updateCartItem\`, \`client.removeCartItem\`, \`client.applyCoupon\`, \`client.removeCoupon\`. After each mutation the SDK returns the updated cart.
3384
4181
  - **Totals:** call \`getCartTotals(cart)\` \u2014 do NOT read \`cart.total\`. The helper understands taxes, shipping, and discounts.
3385
- - **\`smartGetCart()\`** returns \`Cart | LocalCart\` (guest users have no server cart until they add an item). Check \`'id' in cart\` before using server-only fields.
4182
+ - **\`smartGetCart()\`** returns \`CartWithIncludes\` (extends \`Cart\`). All carts are server-side. Pass \`{ include: ['recommendations', 'upgrades', 'bundles'] }\` to fetch extras in one request.
3386
4183
  - **NEVER mutate cart state outside SDK helpers.** Any hand-rolled cart update risks desync with the reservation timer and the checkout flow.`
3387
4184
  },
3388
4185
  "inventory-reservation": {
@@ -3396,6 +4193,54 @@ Build the OAuth button region AND the callback handler even when no providers ar
3396
4193
  - **On the cart page:** show per-item availability \u2014 items that can no longer be purchased (because their reservation expired or the stock dropped) should display an "out of stock" badge and the "proceed to checkout" action should be disabled until they are removed.
3397
4194
 
3398
4195
  The reservation strategy (\`HARD\` vs \`SOFT\`) is exposed via \`get-store-capabilities\`. A HARD reservation means the stock is physically held; a SOFT reservation just tracks intent. Your UI behaves identically either way \u2014 the SDK hides the difference.`
4196
+ },
4197
+ "product-customization": {
4198
+ title: "Product customization (buyer input)",
4199
+ body: `Products can ship with \`customizationFields: ProductCustomizationField[]\` \u2014 buyer-filled inputs (engraving text, uploaded photo, pick-a-color, etc.). If you ignore them, merchants lose the data they need to fulfill the order.
4200
+
4201
+ 1. **Read the fields** from the product payload. Sort by \`position\`. If the array is empty, nothing to render.
4202
+ 2. **Render one control per field** based on \`type\`:
4203
+ - \`TEXT\` / \`URL\` / \`COLOR\` / \`DIMENSION\` / \`WEIGHT\` \u2192 \`<input type="text">\` (honour \`minLength\`/\`maxLength\`)
4204
+ - \`TEXTAREA\` \u2192 \`<textarea>\`
4205
+ - \`NUMBER\` \u2192 \`<input type="number">\` (honour \`minValue\`/\`maxValue\`)
4206
+ - \`BOOLEAN\` \u2192 checkbox (value \`true\`/\`false\`)
4207
+ - \`DATE\` \u2192 \`<input type="date">\` (ISO \`YYYY-MM-DD\`)
4208
+ - \`DATETIME\` \u2192 \`<input type="datetime-local">\` (ISO timestamp)
4209
+ - \`SELECT\` \u2192 \`<select>\` populated from \`enumValues\` (value = one string)
4210
+ - \`MULTI_SELECT\` \u2192 checkbox group from \`enumValues\` (value = \`string[]\`)
4211
+ - \`IMAGE\` \u2192 file input + preview (value = one URL string)
4212
+ - \`GALLERY\` \u2192 multi-file input (value = \`string[]\` of URLs)
4213
+ - \`JSON\` \u2192 advanced; render an admin-style editor or skip unless you control the data
4214
+ 3. **For IMAGE / GALLERY types: upload FIRST, then attach the URL.** Call \`client.uploadCustomizationFile(file)\` \u2014 it returns \`{ url }\`. Put that \`url\` string into the field value. NEVER put a \`File\` object in cart metadata.
4215
+ 4. **Enforce \`required: true\` client-side** before add-to-cart \u2014 show a red hint, block submit. The server re-validates; you want the user to fix it before the request fails.
4216
+ 5. **Add to cart with metadata keyed by \`field.key\`:**
4217
+ \`\`\`ts
4218
+ await client.addToCart({
4219
+ productId,
4220
+ quantity: 1,
4221
+ metadata: {
4222
+ engraving_text: 'For Mom',
4223
+ frame_color: 'Gold',
4224
+ upload_photo: photoUrl, // from uploadCustomizationFile()
4225
+ addons: ['Gift wrap'], // MULTI_SELECT
4226
+ },
4227
+ });
4228
+ \`\`\`
4229
+ 6. **On cart / checkout UIs, surface the metadata.** \`CartItem.metadata\` and \`CheckoutLineItem.metadata\` carry the values the buyer submitted. Show them in the line-item row so the buyer can verify before paying (especially uploaded image thumbnails).
4230
+ 7. **After the order is placed, values live on \`OrderItem.customizations\`** \u2014 a \`Record<string, { label, value, type }>\` keyed by field key. Definitions may be renamed/deleted after the order; the snapshot preserves what the buyer saw at purchase time.
4231
+
4232
+ **Apply-to-all fields.** A merchant can flag a \`MetafieldDefinition\` with \`appliesToAllProducts: true\` \u2014 the backend then includes it in every product's \`customizationFields\` array automatically, including products created after the flag was set. Your client code reads \`product.customizationFields\` as-is and never merges or unions anything \u2014 just render what's there.
4233
+
4234
+ Server-side guardrails that WILL reject bad requests (so validate client-side to avoid round-trips):
4235
+ - Unknown keys \u2192 rejected. Only use keys that appear in \`product.customizationFields\`.
4236
+ - Missing \`required\` fields \u2192 rejected.
4237
+ - \`SELECT\` value not in \`enumValues\` \u2192 rejected.
4238
+ - \`MULTI_SELECT\` value not \`string[]\` OR containing values outside \`enumValues\` \u2192 rejected.
4239
+ - \`IMAGE\` / \`GALLERY\` values must be URLs returned from \`/customization-upload\` on this store \u2014 pasting an external URL is rejected.
4240
+ - Upload > 5MB or non-image MIME \u2192 rejected by upload endpoint.
4241
+ - More than 10 uploads per IP per minute \u2192 429.
4242
+
4243
+ Never render customization fields without also wiring the upload + metadata flow \u2014 a form that submits nothing is worse than no form at all.`
3399
4244
  }
3400
4245
  };
3401
4246
  var FLOW_ORDER = [
@@ -3406,7 +4251,8 @@ var FLOW_ORDER = [
3406
4251
  "oauth",
3407
4252
  "order-confirmation",
3408
4253
  "cart-persistence",
3409
- "inventory-reservation"
4254
+ "inventory-reservation",
4255
+ "product-customization"
3410
4256
  ];
3411
4257
  async function handleGetBusinessFlows(args) {
3412
4258
  const header = "# Brainerce Business Flows\n\nThese sequences are framework-neutral and non-negotiable. Your framework and file layout are your choice \u2014 the order of SDK calls and the error handling is not.";
@@ -3471,6 +4317,14 @@ var FEATURES = [
3471
4317
  sdk: "client.getProductBySlug(slug). Helpers: getProductPriceInfo, getVariantPrice, getStockStatus, getVariantOptions, getProductSwatches, getDescriptionContent, getProductMetafieldValue.",
3472
4318
  mandatory: "mandatory"
3473
4319
  },
4320
+ {
4321
+ id: "product-customization-fields",
4322
+ title: "Render buyer-input customization fields on the product page",
4323
+ description: "When a product ships with `customizationFields` (engraving text, uploaded photo, picked color, date, select / multi-select), render one control per field sorted by `position`. Enforce required + enumValues + min/max client-side. For IMAGE / GALLERY, upload via client.uploadCustomizationFile() and place the returned URL in the value. Submit all values keyed by `field.key` inside addToCart({ metadata }). Show the metadata on cart / checkout rows so the buyer can verify. Build the UI anyway \u2014 it auto-hides when a product has no customization fields. NOTE: merchants can flag a definition with `appliesToAllProducts: true`; the backend folds those into every product's `customizationFields` array automatically \u2014 the client never merges or unions anything, just reads `product.customizationFields` as-is.",
4324
+ sdk: "product.customizationFields, client.uploadCustomizationFile(file), client.addToCart({ productId, quantity, metadata })",
4325
+ flowRef: "product-customization",
4326
+ mandatory: "mandatory"
4327
+ },
3474
4328
  {
3475
4329
  id: "cart",
3476
4330
  title: "Manage a cart with quantity, removal, and totals",