@brainerce/mcp-server 2.4.0 → 2.6.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.mjs CHANGED
@@ -152,7 +152,7 @@ import {
152
152
  getVariantPrice, getVariantOptions, getStockStatus,
153
153
  getCartItemName, getCartItemImage,
154
154
  getDescriptionContent, isHtmlDescription,
155
- getProductMetafieldValue,
155
+ getProductMetafieldValue, getProductCustomizationFields,
156
156
  } from 'brainerce';
157
157
  \`\`\``;
158
158
  }
@@ -194,6 +194,7 @@ async function startCheckout() {
194
194
  4. Start checkout session (\`startGuestCheckout()\` or \`createCheckout()\`)
195
195
  5. Set shipping address (includes required email) \u2192 \`setShippingAddress()\`
196
196
  6. Select shipping method \u2192 \`selectShippingMethod()\`
197
+ 6b. (Optional) Set checkout custom fields \u2192 \`setCheckoutCustomFields()\` \u2014 surcharges auto-calculated
197
198
  7. Create payment intent \u2192 \`createPaymentIntent()\` \u2192 returns \`{ clientSecret, provider }\`
198
199
  8. Branch on \`provider\`: \`'stripe'\` \u2192 Stripe Elements, \`'grow'\` \u2192 iframe, \`'paypal'\` \u2192 PayPal Buttons
199
200
  9. **Order is created AUTOMATICALLY after payment succeeds (via webhook) \u2014 for ALL providers!**
@@ -576,6 +577,35 @@ const material = getProductMetafieldValue(product, 'material');
576
577
 
577
578
  **Metafield fields:** \`definitionName\` (display label), \`definitionKey\` (lookup key), \`value\`, \`type\` (IMAGE, GALLERY, URL, COLOR, BOOLEAN, DATE, DATETIME, TEXT, TEXTAREA, NUMBER, DIMENSION, WEIGHT, JSON)
578
579
 
580
+ ### Product Customization Fields (Customer Input)
581
+
582
+ 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.
583
+
584
+ \`\`\`typescript
585
+ import { getProductCustomizationFields } from 'brainerce';
586
+ import type { ProductCustomizationField } from 'brainerce';
587
+
588
+ const fields = getProductCustomizationFields(product);
589
+
590
+ // Render input for each field based on field.type:
591
+ // TEXT/TEXTAREA \u2192 text input, NUMBER \u2192 number input, BOOLEAN \u2192 checkbox,
592
+ // COLOR \u2192 color picker, DATE \u2192 date picker, IMAGE \u2192 file upload, etc.
593
+ // Check field.required, field.minLength, field.maxLength, field.enumValues for validation.
594
+
595
+ // Pass customer values in metadata when adding to cart:
596
+ await client.addToCart(cartId, {
597
+ productId: product.id,
598
+ quantity: 1,
599
+ metadata: { cake_text: 'Happy Birthday!' },
600
+ });
601
+
602
+ // For IMAGE fields, upload first:
603
+ const { url } = await client.uploadCustomizationFile(file);
604
+ // Then use the URL in metadata: metadata: { logo: url }
605
+ \`\`\`
606
+
607
+ **Customization field properties:** \`key\`, \`name\` (display label), \`type\`, \`required\`, \`minLength\`, \`maxLength\`, \`minValue\`, \`maxValue\`, \`enumValues\`, \`defaultValue\`, \`position\`
608
+
579
609
  ### Downloadable / Digital Products
580
610
 
581
611
  Products with \`isDownloadable: true\` are digital products. Show a digital badge instead of stock badge, list included files, and note that download links appear after purchase.
@@ -762,9 +792,14 @@ const { bundles } = await client.getCartBundles(cartId);
762
792
  // bundles[].bundleProduct \u2014 the product to offer
763
793
  // bundles[].originalPrice / bundles[].discountedPrice \u2014 prices
764
794
  // bundles[].discountType ('PERCENTAGE' | 'FIXED_AMOUNT') + discountValue
795
+ // bundles[].requiresVariantSelection \u2014 true if customer must pick a variant
796
+ // bundles[].lockedVariant \u2014 set if admin pre-selected a specific variant
797
+ // bundles[].bundleProduct.variants \u2014 available variants (only when requiresVariantSelection)
765
798
 
766
799
  // Add a bundle to cart (applies the discount automatically):
767
800
  await client.addBundleToCart(cartId, bundleOfferId);
801
+ // For products with variants \u2014 pass the selected variantId:
802
+ await client.addBundleToCart(cartId, bundleOfferId, selectedVariantId);
768
803
  // Remove a bundle from cart:
769
804
  await client.removeBundleFromCart(cartId, bundleOfferId);
770
805
  // Detect already-added bundles: check cart.items for metadata?.isBundleItem === true
@@ -789,9 +824,14 @@ const { bumps } = await client.getCheckoutBumps(checkoutId);
789
824
  // bumps[].bumpProduct \u2014 product to offer
790
825
  // bumps[].originalPrice / discountedPrice \u2014 with optional discount
791
826
  // bumps[].title / description \u2014 display text
827
+ // bumps[].requiresVariantSelection \u2014 true if customer must pick a variant
828
+ // bumps[].lockedVariant \u2014 set if admin pre-selected a specific variant
829
+ // bumps[].bumpProduct.variants \u2014 available variants (only when requiresVariantSelection)
792
830
 
793
831
  // Add a bump to cart:
794
832
  await client.addOrderBump(cartId, bumpId);
833
+ // For products with variants \u2014 pass the selected variantId:
834
+ await client.addOrderBump(cartId, bumpId, selectedVariantId);
795
835
  // Remove a bump:
796
836
  await client.removeOrderBump(cartId, bumpId);
797
837
  // Detect already-added bumps: check cart.items for metadata?.isOrderBump === true
@@ -816,10 +856,11 @@ Always check the flag before rendering: \`if (storeInfo?.upsell?.featureName !==
816
856
  | CartRecommendationSection | products/ | Cross-sell grid at bottom of cart page |
817
857
  | FreeShippingBar | cart/ | Progress bar toward free shipping threshold |
818
858
  | CartUpgradeBanner | cart/ | Inline "Upgrade to X for +$Y" per cart item |
819
- | CartBundleOfferCard | cart/ | Discounted bundle offer card in cart |
820
- | OrderBumpCard | checkout/ | Checkbox add-on card in checkout sidebar |
859
+ | CartBundleOfferCard | cart/ | Discounted bundle offer card in cart (with inline variant selector for variable products) |
860
+ | OrderBumpCard | checkout/ | Checkbox add-on card in checkout sidebar (with inline variant selector for variable products) |
821
861
 
822
- ProductRecommendation: \`id\`, \`name\`, \`slug\`, \`basePrice\`, \`salePrice\`, \`images\`, \`type\`, \`inventory\`, \`relationType\`.`;
862
+ ProductRecommendation: \`id\`, \`name\`, \`slug\`, \`basePrice\`, \`salePrice\`, \`images\`, \`type\`, \`inventory\`, \`relationType\`, \`variants?\` (when variant selection needed).
863
+ OrderBump/CartBundleOffer: includes \`requiresVariantSelection\` (boolean) and \`lockedVariant?\` (\`{ id, name, attributes }\`).`;
823
864
  }
824
865
  function getInventorySection() {
825
866
  return `## Inventory, Stock Display & Reservation Countdown
@@ -1959,6 +2000,15 @@ export function getClient(): BrainerceClient {
1959
2000
  }
1960
2001
  return clientInstance;
1961
2002
  }
2003
+ <% if (i18nEnabled) { %>
2004
+
2005
+ /** Initialize client with a specific locale for translated content */
2006
+ export function initClientWithLocale(locale: string): BrainerceClient {
2007
+ const client = getClient();
2008
+ client.setLocale(locale);
2009
+ return client;
2010
+ }
2011
+ <% } %>
1962
2012
 
1963
2013
  // Cart ID helpers (not a security token \u2014 safe in localStorage)
1964
2014
  const CART_ID_KEY = 'brainerce_cart_id';
@@ -2157,6 +2207,10 @@ import type { StoreInfo, Cart, CustomerProfile } from 'brainerce';
2157
2207
  import { getCartTotals } from 'brainerce';
2158
2208
  import { getClient, initClient, setStoredCartId } from '@/lib/brainerce';
2159
2209
  import { checkAuthStatus, proxyLogout } from '@/lib/auth';
2210
+ <% if (i18nEnabled) { %>
2211
+ import { MessagesContext } from '@/lib/translations';
2212
+ import { getMessages, defaultLocale } from '@/i18n';
2213
+ <% } %>
2160
2214
 
2161
2215
  // ---- Store Info Context ----
2162
2216
  interface StoreInfoContextValue {
@@ -2216,7 +2270,11 @@ export function useCart() {
2216
2270
  }
2217
2271
 
2218
2272
  // ---- Provider Component ----
2273
+ <% if (i18nEnabled) { %>
2274
+ export function StoreProvider({ children, locale }: { children: React.ReactNode; locale?: string }) {
2275
+ <% } else { %>
2219
2276
  export function StoreProvider({ children }: { children: React.ReactNode }) {
2277
+ <% } %>
2220
2278
  const [storeInfo, setStoreInfo] = useState<StoreInfo | null>(null);
2221
2279
  const [storeLoading, setStoreLoading] = useState(true);
2222
2280
  const [isLoggedIn, setIsLoggedIn] = useState(false);
@@ -2224,6 +2282,9 @@ export function StoreProvider({ children }: { children: React.ReactNode }) {
2224
2282
  const [authLoading, setAuthLoading] = useState(true);
2225
2283
  const [cart, setCart] = useState<Cart | null>(null);
2226
2284
  const [cartLoading, setCartLoading] = useState(true);
2285
+ <% if (i18nEnabled) { %>
2286
+ const [messages, setMessages] = useState<Record<string, Record<string, string>>>({});
2287
+ <% } %>
2227
2288
 
2228
2289
  // Check auth status via httpOnly cookie (server-side validation)
2229
2290
  const refreshAuth = useCallback(async () => {
@@ -2242,6 +2303,16 @@ export function StoreProvider({ children }: { children: React.ReactNode }) {
2242
2303
  // Initialize client, check auth, and fetch store info
2243
2304
  useEffect(() => {
2244
2305
  const client = initClient();
2306
+ <% if (i18nEnabled) { %>
2307
+
2308
+ // Set locale on SDK client for translated product content
2309
+ if (locale) {
2310
+ client.setLocale(locale);
2311
+ }
2312
+
2313
+ // Load UI message strings for current locale
2314
+ getMessages(locale || defaultLocale).then(setMessages);
2315
+ <% } %>
2245
2316
 
2246
2317
  // Optimistic check: if brainerce_logged_in cookie exists, assume logged in
2247
2318
  // while we validate the actual token server-side
@@ -2258,7 +2329,11 @@ export function StoreProvider({ children }: { children: React.ReactNode }) {
2258
2329
  .then(setStoreInfo)
2259
2330
  .catch(console.error)
2260
2331
  .finally(() => setStoreLoading(false));
2332
+ <% if (i18nEnabled) { %>
2333
+ }, [refreshAuth, locale]);
2334
+ <% } else { %>
2261
2335
  }, [refreshAuth]);
2336
+ <% } %>
2262
2337
 
2263
2338
  // Cart management
2264
2339
  const refreshCart = useCallback(async () => {
@@ -2311,6 +2386,21 @@ export function StoreProvider({ children }: { children: React.ReactNode }) {
2311
2386
 
2312
2387
  const totals = cart ? getCartTotals(cart) : { subtotal: 0, discount: 0, shipping: 0, total: 0 };
2313
2388
 
2389
+ <% if (i18nEnabled) { %>
2390
+ return (
2391
+ <MessagesContext.Provider value={messages}>
2392
+ <StoreInfoContext.Provider value={{ storeInfo, loading: storeLoading }}>
2393
+ <AuthContext.Provider value={{ isLoggedIn, authLoading, customer, login, logout }}>
2394
+ <CartContext.Provider
2395
+ value={{ cart, cartLoading, refreshCart, itemCount, totals }}
2396
+ >
2397
+ {children}
2398
+ </CartContext.Provider>
2399
+ </AuthContext.Provider>
2400
+ </StoreInfoContext.Provider>
2401
+ </MessagesContext.Provider>
2402
+ );
2403
+ <% } else { %>
2314
2404
  return (
2315
2405
  <StoreInfoContext.Provider value={{ storeInfo, loading: storeLoading }}>
2316
2406
  <AuthContext.Provider value={{ isLoggedIn, authLoading, customer, login, logout }}>
@@ -2322,6 +2412,7 @@ export function StoreProvider({ children }: { children: React.ReactNode }) {
2322
2412
  </AuthContext.Provider>
2323
2413
  </StoreInfoContext.Provider>
2324
2414
  );
2415
+ <% } %>
2325
2416
  }
2326
2417
  `,
2327
2418
  "src/app/page.tsx": `'use client';
@@ -2423,7 +2514,81 @@ export default function HomePage() {
2423
2514
  );
2424
2515
  }
2425
2516
  `,
2426
- "src/app/layout.tsx.ejs": `import type { Metadata } from 'next';
2517
+ "src/app/layout.tsx.ejs": `<% if (i18nEnabled) { %>
2518
+ import type { Metadata } from 'next';
2519
+ <%- fontImport %>
2520
+ import { StoreProvider } from '@/providers/store-provider';
2521
+ import { Header } from '@/components/layout/header';
2522
+ import { Footer } from '@/components/layout/footer';
2523
+ import { getDirection, supportedLocales } from '@/i18n';
2524
+ import '../globals.css';
2525
+
2526
+ <%- fontVariable %>
2527
+
2528
+ const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://example.com';
2529
+
2530
+ export const metadata: Metadata = {
2531
+ metadataBase: new URL(baseUrl),
2532
+ title: {
2533
+ default: 'My Store',
2534
+ template: \`%s | My Store\`,
2535
+ },
2536
+ description: 'My Store',
2537
+ alternates: {
2538
+ canonical: '/',
2539
+ },
2540
+ openGraph: {
2541
+ siteName: 'My Store',
2542
+ type: 'website',
2543
+ },
2544
+ robots: {
2545
+ index: true,
2546
+ follow: true,
2547
+ },
2548
+ };
2549
+
2550
+ const organizationJsonLd = {
2551
+ '@context': 'https://schema.org',
2552
+ '@type': 'Organization',
2553
+ name: 'My Store',
2554
+ url: baseUrl,
2555
+ };
2556
+
2557
+ export function generateStaticParams() {
2558
+ return supportedLocales.map((locale) => ({ locale }));
2559
+ }
2560
+
2561
+ export default function RootLayout({
2562
+ children,
2563
+ params,
2564
+ }: {
2565
+ children: React.ReactNode;
2566
+ params: { locale: string };
2567
+ }) {
2568
+ const dir = getDirection(params.locale);
2569
+
2570
+ return (
2571
+ <html lang={params.locale} dir={dir}>
2572
+ <head>
2573
+ <script
2574
+ type="application/ld+json"
2575
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(organizationJsonLd) }}
2576
+ />
2577
+ </head>
2578
+ <body className={font.className}>
2579
+ <StoreProvider locale={params.locale}>
2580
+ <div className="min-h-screen flex flex-col">
2581
+ <Header />
2582
+ <main className="flex-1">{children}</main>
2583
+ <Footer />
2584
+ </div>
2585
+ </StoreProvider>
2586
+ </body>
2587
+ </html>
2588
+ );
2589
+ }
2590
+ <% } else { %>
2591
+ import type { Metadata } from 'next';
2427
2592
  <%- fontImport %>
2428
2593
  import { StoreProvider } from '@/providers/store-provider';
2429
2594
  import { Header } from '@/components/layout/header';
@@ -2487,6 +2652,7 @@ export default function RootLayout({
2487
2652
  </html>
2488
2653
  );
2489
2654
  }
2655
+ <% } %>
2490
2656
  `,
2491
2657
  "src/app/products/page.tsx": `'use client';
2492
2658
 
@@ -3387,13 +3553,13 @@ function CheckoutContent() {
3387
3553
  }, [checkout?.id, storeInfo?.upsell?.checkoutOrderBumpEnabled]);
3388
3554
 
3389
3555
  // Handle bump toggle
3390
- async function handleBumpToggle(bumpId: string, add: boolean) {
3556
+ async function handleBumpToggle(bumpId: string, add: boolean, variantId?: string) {
3391
3557
  if (!cart?.id || bumpLoading) return;
3392
3558
  try {
3393
3559
  setBumpLoading(bumpId);
3394
3560
  const client = getClient();
3395
3561
  if (add) {
3396
- await client.addOrderBump(cart.id, bumpId);
3562
+ await client.addOrderBump(cart.id, bumpId, variantId);
3397
3563
  setAddedBumpIds((prev) => new Set([...prev, bumpId]));
3398
3564
  } else {
3399
3565
  await client.removeOrderBump(cart.id, bumpId);
@@ -4091,6 +4257,23 @@ function OrderConfirmationContent() {
4091
4257
  client.handlePaymentSuccess(checkoutId!);
4092
4258
  await refreshCart();
4093
4259
 
4260
+ // For redirect-based payment providers (e.g. CardCom), the customer
4261
+ // returns with provider params in the URL (lowprofilecode, etc.).
4262
+ // Send these to the backend for server-side verification via the
4263
+ // provider's API (e.g. GetLpResult) \u2014 never trust URL params alone.
4264
+ const lowProfileCode =
4265
+ searchParams.get('lowprofilecode') || searchParams.get('LowProfileCode');
4266
+ if (lowProfileCode) {
4267
+ try {
4268
+ await client.confirmSdkPayment(checkoutId!, {
4269
+ paymentIntentId: lowProfileCode,
4270
+ });
4271
+ } catch (err) {
4272
+ console.warn('Redirect payment confirmation failed:', err);
4273
+ // Don't block \u2014 webhook may still process the payment
4274
+ }
4275
+ }
4276
+
4094
4277
  const orderResult = await client.waitForOrder(checkoutId!, {
4095
4278
  maxWaitMs: 30000,
4096
4279
  });
@@ -5607,7 +5790,62 @@ export async function POST(request: NextRequest) {
5607
5790
  return response;
5608
5791
  }
5609
5792
  `,
5610
- "src/middleware.ts": `import { NextRequest, NextResponse } from 'next/server';
5793
+ "src/middleware.ts.ejs": `<% if (i18nEnabled) { %>
5794
+ import { NextRequest, NextResponse } from 'next/server';
5795
+
5796
+ const TOKEN_COOKIE = 'brainerce_customer_token';
5797
+ const PROTECTED_PATHS = ['/account'];
5798
+ const supportedLocales = <%- supportedLocales %>;
5799
+ const defaultLocale = '<%= defaultLocale %>';
5800
+
5801
+ function getLocaleFromPath(pathname: string): string | null {
5802
+ const segment = pathname.split('/')[1];
5803
+ return supportedLocales.includes(segment) ? segment : null;
5804
+ }
5805
+
5806
+ export function middleware(request: NextRequest) {
5807
+ const { pathname } = request.nextUrl;
5808
+
5809
+ // Skip static files and API routes
5810
+ if (
5811
+ pathname.startsWith('/api/') ||
5812
+ pathname.startsWith('/_next/') ||
5813
+ pathname.includes('.')
5814
+ ) {
5815
+ return NextResponse.next();
5816
+ }
5817
+
5818
+ const pathnameLocale = getLocaleFromPath(pathname);
5819
+
5820
+ // Redirect to default locale if no locale prefix
5821
+ if (!pathnameLocale) {
5822
+ const url = request.nextUrl.clone();
5823
+ url.pathname = \`/\${defaultLocale}\${pathname}\`;
5824
+ return NextResponse.redirect(url);
5825
+ }
5826
+
5827
+ // Auth protection (with locale prefix)
5828
+ const pathWithoutLocale = pathname.replace(\`/\${pathnameLocale}\`, '') || '/';
5829
+ const isProtected = PROTECTED_PATHS.some((p) => pathWithoutLocale.startsWith(p));
5830
+ if (isProtected) {
5831
+ const token = request.cookies.get(TOKEN_COOKIE);
5832
+ if (!token?.value) {
5833
+ const loginUrl = new URL(\`/\${pathnameLocale}/login\`, request.url);
5834
+ return NextResponse.redirect(loginUrl);
5835
+ }
5836
+ }
5837
+
5838
+ // Set locale header for server components
5839
+ const response = NextResponse.next();
5840
+ response.headers.set('x-locale', pathnameLocale);
5841
+ return response;
5842
+ }
5843
+
5844
+ export const config = {
5845
+ matcher: ['/((?!_next|api|.*\\\\..*).*)'],
5846
+ };
5847
+ <% } else { %>
5848
+ import { NextRequest, NextResponse } from 'next/server';
5611
5849
 
5612
5850
  const TOKEN_COOKIE = 'brainerce_customer_token';
5613
5851
 
@@ -5632,6 +5870,7 @@ export function middleware(request: NextRequest) {
5632
5870
  export const config = {
5633
5871
  matcher: ['/account/:path*'],
5634
5872
  };
5873
+ <% } %>
5635
5874
  `,
5636
5875
  "src/components/products/product-card.tsx": `'use client';
5637
5876
 
@@ -7493,7 +7732,9 @@ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
7493
7732
  initialized.current = true;
7494
7733
 
7495
7734
  const client = getClient();
7496
- const successUrl = \`\${window.location.origin}/order-confirmation?checkout_id=\${checkoutId}\`;
7735
+ const iframeSuccessUrl = \`\${window.location.origin}/payment-complete?checkout_id=\${checkoutId}\`;
7736
+ const iframeFailedUrl = \`\${window.location.origin}/payment-complete?checkout_id=\${checkoutId}&failed=true\`;
7737
+ const redirectSuccessUrl = \`\${window.location.origin}/order-confirmation?checkout_id=\${checkoutId}\`;
7497
7738
  const cancelUrl = \`\${window.location.origin}/checkout?checkout_id=\${checkoutId}&canceled=true\`;
7498
7739
 
7499
7740
  let sdkInitDone = false;
@@ -7654,8 +7895,19 @@ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
7654
7895
  });
7655
7896
 
7656
7897
  // C) Create payment intent (starts wallet timer)
7657
- const intentPromise = client
7658
- .createPaymentIntent(checkoutId, { successUrl, cancelUrl })
7898
+ // Wait for provider info so we can choose the right success URL:
7899
+ // iframe providers redirect inside the iframe to /payment-complete (postMessage),
7900
+ // redirect providers go straight to /order-confirmation.
7901
+ const intentPromise = providerPromise
7902
+ .then((providerSdk) => {
7903
+ const isIframe = providerSdk?.renderType === 'iframe';
7904
+ const successUrl = isIframe ? iframeSuccessUrl : redirectSuccessUrl;
7905
+ const failedUrl = isIframe ? iframeFailedUrl : cancelUrl;
7906
+ return client.createPaymentIntent(checkoutId, {
7907
+ successUrl,
7908
+ cancelUrl: failedUrl,
7909
+ });
7910
+ })
7659
7911
  .then((intent) => {
7660
7912
  setPaymentIntent(intent);
7661
7913
  return intent;
@@ -7680,6 +7932,36 @@ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
7680
7932
  window.location.href = intent.clientSecret;
7681
7933
  return;
7682
7934
  }
7935
+
7936
+ // Iframe mode: listen for postMessage from the /payment-complete callback
7937
+ // page that loads inside the iframe after the provider redirects on completion.
7938
+ if (sdk.renderType === 'iframe') {
7939
+ const handleMessage = (event: MessageEvent) => {
7940
+ if (event.origin !== window.location.origin) return;
7941
+ if (event.data?.type !== 'brainerce:payment-complete') return;
7942
+
7943
+ const params = event.data.data as Record<string, string> | undefined;
7944
+ if (params?.failed === 'true') {
7945
+ setError(t('paymentError'));
7946
+ return;
7947
+ }
7948
+
7949
+ // Map provider-specific params to normalized format for
7950
+ // server-side verification (e.g. CardCom lowprofilecode \u2192 paymentIntentId)
7951
+ const lowProfileCode = params?.lowprofilecode || params?.LowProfileCode;
7952
+ const normalized: Record<string, unknown> = { ...params };
7953
+ if (lowProfileCode) {
7954
+ normalized.paymentIntentId = lowProfileCode;
7955
+ }
7956
+
7957
+ // Trigger server-side verification + order creation
7958
+ handleSuccess(normalized);
7959
+ };
7960
+ window.addEventListener('message', handleMessage);
7961
+ cleanups.push(() => window.removeEventListener('message', handleMessage));
7962
+ return;
7963
+ }
7964
+
7683
7965
  if (sdk.renderType !== 'sdk-widget' || !sdk.globalName) return;
7684
7966
 
7685
7967
  // Store for retryRender from onError callback
@@ -7830,15 +8112,45 @@ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
7830
8112
 
7831
8113
  if (sdk.renderType === 'iframe') {
7832
8114
  return (
7833
- <div className={cn('py-4', className)}>
7834
- <iframe
7835
- src={paymentIntent.clientSecret}
7836
- className="w-full border-0"
7837
- style={{ minHeight: '500px' }}
7838
- title={t('payment')}
7839
- allow="payment"
7840
- />
7841
- </div>
8115
+ <>
8116
+ {/* Modal overlay */}
8117
+ <div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 py-6 backdrop-blur-sm">
8118
+ <div className="relative mx-4 w-full max-w-md rounded-2xl bg-white shadow-2xl">
8119
+ {/* Close button */}
8120
+ <button
8121
+ onClick={() => {
8122
+ window.location.href = \`/checkout?checkout_id=\${checkoutId}&canceled=true\`;
8123
+ }}
8124
+ className="absolute end-3 top-3 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-white/80 text-gray-500 shadow-sm transition-colors hover:bg-gray-100 hover:text-gray-700"
8125
+ aria-label="Close"
8126
+ >
8127
+ <svg
8128
+ width="14"
8129
+ height="14"
8130
+ viewBox="0 0 14 14"
8131
+ fill="none"
8132
+ stroke="currentColor"
8133
+ strokeWidth="2"
8134
+ strokeLinecap="round"
8135
+ >
8136
+ <path d="M1 1l12 12M13 1L1 13" />
8137
+ </svg>
8138
+ </button>
8139
+ <iframe
8140
+ src={paymentIntent.clientSecret}
8141
+ className="w-full rounded-2xl border-0"
8142
+ style={{ height: '80vh' }}
8143
+ title={t('payment')}
8144
+ allow="payment"
8145
+ />
8146
+ </div>
8147
+ </div>
8148
+ {/* Placeholder so the checkout layout doesn't collapse */}
8149
+ <div className={cn('flex flex-col items-center justify-center py-12', className)}>
8150
+ <LoadingSpinner size="lg" />
8151
+ <p className="text-muted-foreground mt-4 text-sm">{t('preparingPayment')}</p>
8152
+ </div>
8153
+ </>
7842
8154
  );
7843
8155
  }
7844
8156
 
@@ -9965,10 +10277,10 @@ export function CartUpgradeBanner({
9965
10277
  `,
9966
10278
  "src/components/cart/cart-bundle-offer.tsx": `'use client';
9967
10279
 
9968
- import { useState } from 'react';
10280
+ import { useState, useMemo } from 'react';
9969
10281
  import Image from 'next/image';
9970
10282
  import type { CartBundleOffer as CartBundleOfferType } from 'brainerce';
9971
- import { formatPrice } from 'brainerce';
10283
+ import { formatPrice, getVariantOptions } from 'brainerce';
9972
10284
  import { useStoreInfo } from '@/providers/store-provider';
9973
10285
  import { useTranslations } from '@/lib/translations';
9974
10286
  import { cn } from '@/lib/utils';
@@ -9985,28 +10297,114 @@ export function CartBundleOfferCard({ offer, cartId, onAdd, className }: CartBun
9985
10297
  const t = useTranslations('cart');
9986
10298
  const currency = storeInfo?.currency || 'USD';
9987
10299
  const [adding, setAdding] = useState(false);
10300
+ const [selectedVariantId, setSelectedVariantId] = useState<string | null>(null);
9988
10301
 
9989
10302
  const product = offer.bundleProduct;
10303
+ const variants = product.variants;
10304
+ const requiresSelection = offer.requiresVariantSelection && variants && variants.length > 0;
10305
+
10306
+ // Build attribute groups from variants
10307
+ const attributeGroups = useMemo(() => {
10308
+ if (!requiresSelection || !variants) return [];
10309
+ const groups = new Map<string, Set<string>>();
10310
+ for (const v of variants) {
10311
+ const opts = getVariantOptions(v as any);
10312
+ for (const opt of opts) {
10313
+ if (!groups.has(opt.name)) groups.set(opt.name, new Set());
10314
+ groups.get(opt.name)!.add(opt.value);
10315
+ }
10316
+ }
10317
+ return Array.from(groups.entries()).map(([name, values]) => ({
10318
+ name,
10319
+ values: Array.from(values),
10320
+ }));
10321
+ }, [requiresSelection, variants]);
10322
+
10323
+ const [selectedAttrs, setSelectedAttrs] = useState<Record<string, string>>({});
10324
+
10325
+ const selectedVariant = useMemo(() => {
10326
+ if (!requiresSelection || !variants) return null;
10327
+ return (
10328
+ variants.find((v) => {
10329
+ const opts = getVariantOptions(v as any);
10330
+ return attributeGroups.every((group) => {
10331
+ const opt = opts.find((o) => o.name === group.name);
10332
+ return opt && selectedAttrs[group.name] === opt.value;
10333
+ });
10334
+ }) ?? null
10335
+ );
10336
+ }, [requiresSelection, variants, selectedAttrs, attributeGroups]);
10337
+
10338
+ const effectiveVariantId = selectedVariant?.id ?? selectedVariantId;
10339
+
10340
+ function handleAttrSelect(attrName: string, value: string) {
10341
+ const next = { ...selectedAttrs, [attrName]: value };
10342
+ setSelectedAttrs(next);
10343
+ if (variants) {
10344
+ const match = variants.find((v) => {
10345
+ const opts = getVariantOptions(v as any);
10346
+ return attributeGroups.every((group) => {
10347
+ const opt = opts.find((o) => o.name === group.name);
10348
+ return opt && next[group.name] === opt.value;
10349
+ });
10350
+ });
10351
+ setSelectedVariantId(match?.id ?? null);
10352
+ }
10353
+ }
10354
+
10355
+ // Compute display prices
10356
+ const { displayOriginal, displayDiscounted, discountLabel } = useMemo(() => {
10357
+ let effectivePrice: number;
10358
+ if (selectedVariant) {
10359
+ const vSale = selectedVariant.salePrice ? parseFloat(selectedVariant.salePrice) : null;
10360
+ const vPrice = selectedVariant.price ? parseFloat(selectedVariant.price) : null;
10361
+ effectivePrice = vSale ?? vPrice ?? parseFloat(offer.originalPrice);
10362
+ } else {
10363
+ effectivePrice = parseFloat(offer.originalPrice);
10364
+ }
10365
+
10366
+ let discounted: number;
10367
+ if (offer.discountType === 'PERCENTAGE') {
10368
+ discounted = effectivePrice * (1 - parseFloat(offer.discountValue) / 100);
10369
+ } else {
10370
+ discounted = Math.max(0, effectivePrice - parseFloat(offer.discountValue));
10371
+ }
10372
+
10373
+ const label =
10374
+ offer.discountType === 'PERCENTAGE'
10375
+ ? \`\${offer.discountValue}%\`
10376
+ : (formatPrice(parseFloat(offer.discountValue), { currency }) as string);
10377
+
10378
+ return { displayOriginal: effectivePrice, displayDiscounted: discounted, discountLabel: label };
10379
+ }, [selectedVariant, offer.originalPrice, offer.discountType, offer.discountValue, currency]);
10380
+
10381
+ const isOos =
10382
+ selectedVariant?.inventory?.trackingMode !== 'NOT_TRACKED' &&
10383
+ selectedVariant?.inventory?.available != null &&
10384
+ selectedVariant.inventory.available <= 0;
10385
+
10386
+ const lockedLabel =
10387
+ offer.lockedVariant?.name ??
10388
+ (offer.lockedVariant?.attributes
10389
+ ? Object.values(offer.lockedVariant.attributes).join(' / ')
10390
+ : null);
10391
+
9990
10392
  const firstImage = product.images?.[0];
9991
10393
  const imageUrl = firstImage
9992
10394
  ? typeof firstImage === 'string'
9993
10395
  ? firstImage
9994
10396
  : firstImage.url
9995
10397
  : null;
9996
- const originalPrice = parseFloat(offer.originalPrice);
9997
- const discountedPrice = parseFloat(offer.discountedPrice);
9998
- const discountLabel =
9999
- offer.discountType === 'PERCENTAGE'
10000
- ? \`\${offer.discountValue}%\`
10001
- : (formatPrice(parseFloat(offer.discountValue), { currency }) as string);
10398
+
10399
+ const canAdd = !requiresSelection || !!effectiveVariantId;
10002
10400
 
10003
10401
  async function handleAdd() {
10004
- if (adding) return;
10402
+ if (adding || !canAdd || isOos) return;
10005
10403
  try {
10006
10404
  setAdding(true);
10007
10405
  const { getClient } = await import('@/lib/brainerce');
10008
10406
  const client = getClient();
10009
- await client.addBundleToCart(cartId, offer.id);
10407
+ await client.addBundleToCart(cartId, offer.id, effectiveVariantId ?? undefined);
10010
10408
  onAdd();
10011
10409
  } catch (err) {
10012
10410
  console.error('Failed to add bundle item:', err);
@@ -10016,70 +10414,121 @@ export function CartBundleOfferCard({ offer, cartId, onAdd, className }: CartBun
10016
10414
  }
10017
10415
 
10018
10416
  return (
10019
- <div
10020
- className={cn(
10021
- 'bg-background border-border flex items-center gap-4 rounded-lg border p-4',
10022
- className
10023
- )}
10024
- >
10025
- {/* Product image */}
10026
- <div className="bg-muted relative h-16 w-16 flex-shrink-0 overflow-hidden rounded">
10027
- {imageUrl ? (
10028
- <Image src={imageUrl} alt={product.name} fill sizes="64px" className="object-cover" />
10029
- ) : (
10030
- <div className="text-muted-foreground flex h-full w-full items-center justify-center">
10031
- <svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
10032
- <path
10033
- strokeLinecap="round"
10034
- strokeLinejoin="round"
10035
- strokeWidth={1.5}
10036
- d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
10037
- />
10038
- </svg>
10039
- </div>
10040
- )}
10041
- </div>
10417
+ <div className={cn('bg-background border-border rounded-lg border p-4', className)}>
10418
+ <div className="flex items-center gap-4">
10419
+ {/* Product image */}
10420
+ <div className="bg-muted relative h-16 w-16 flex-shrink-0 overflow-hidden rounded">
10421
+ {imageUrl ? (
10422
+ <Image src={imageUrl} alt={product.name} fill sizes="64px" className="object-cover" />
10423
+ ) : (
10424
+ <div className="text-muted-foreground flex h-full w-full items-center justify-center">
10425
+ <svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
10426
+ <path
10427
+ strokeLinecap="round"
10428
+ strokeLinejoin="round"
10429
+ strokeWidth={1.5}
10430
+ d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
10431
+ />
10432
+ </svg>
10433
+ </div>
10434
+ )}
10435
+ </div>
10042
10436
 
10043
- {/* Details */}
10044
- <div className="min-w-0 flex-1">
10045
- <p className="text-foreground text-sm font-medium">{offer.name}</p>
10046
- {offer.description && (
10047
- <p className="text-muted-foreground mt-0.5 text-xs">{offer.description}</p>
10048
- )}
10049
- <div className="mt-1 flex items-center gap-2">
10050
- <span className="text-muted-foreground text-sm line-through">
10051
- {formatPrice(originalPrice, { currency }) as string}
10052
- </span>
10053
- <span className="text-foreground text-sm font-semibold">
10054
- {formatPrice(discountedPrice, { currency }) as string}
10055
- </span>
10056
- <span className="bg-destructive/10 text-destructive rounded px-1.5 py-0.5 text-xs font-medium">
10057
- -{discountLabel}
10058
- </span>
10437
+ {/* Details */}
10438
+ <div className="min-w-0 flex-1">
10439
+ <p className="text-foreground text-sm font-medium">{offer.name}</p>
10440
+ {offer.description && (
10441
+ <p className="text-muted-foreground mt-0.5 text-xs">{offer.description}</p>
10442
+ )}
10443
+ {lockedLabel && <p className="text-muted-foreground mt-0.5 text-xs">{lockedLabel}</p>}
10444
+ <div className="mt-1 flex items-center gap-2">
10445
+ <span className="text-muted-foreground text-sm line-through">
10446
+ {formatPrice(displayOriginal, { currency }) as string}
10447
+ </span>
10448
+ <span className="text-foreground text-sm font-semibold">
10449
+ {formatPrice(displayDiscounted, { currency }) as string}
10450
+ </span>
10451
+ <span className="bg-destructive/10 text-destructive rounded px-1.5 py-0.5 text-xs font-medium">
10452
+ -{discountLabel}
10453
+ </span>
10454
+ </div>
10059
10455
  </div>
10456
+
10457
+ {/* Add button */}
10458
+ <button
10459
+ type="button"
10460
+ onClick={handleAdd}
10461
+ disabled={adding || !canAdd || isOos}
10462
+ className={cn(
10463
+ 'bg-primary text-primary-foreground flex-shrink-0 rounded px-4 py-2 text-xs font-medium transition-opacity hover:opacity-90',
10464
+ 'disabled:cursor-not-allowed disabled:opacity-50'
10465
+ )}
10466
+ >
10467
+ {adding
10468
+ ? t('addingBundle')
10469
+ : requiresSelection && !canAdd
10470
+ ? t('selectOptions') || 'Select options'
10471
+ : t('addBundleItem')}
10472
+ </button>
10060
10473
  </div>
10061
10474
 
10062
- {/* Add button */}
10063
- <button
10064
- type="button"
10065
- onClick={handleAdd}
10066
- disabled={adding}
10067
- className={cn(
10068
- 'bg-primary text-primary-foreground flex-shrink-0 rounded px-4 py-2 text-xs font-medium transition-opacity hover:opacity-90',
10069
- 'disabled:cursor-not-allowed disabled:opacity-50'
10070
- )}
10071
- >
10072
- {adding ? t('addingBundle') : t('addBundleItem')}
10073
- </button>
10475
+ {/* Compact variant selector */}
10476
+ {requiresSelection && (
10477
+ <div className="mt-3 space-y-1.5 ps-20">
10478
+ {attributeGroups.map((group) => (
10479
+ <div key={group.name} className="flex flex-wrap items-center gap-1.5">
10480
+ <span className="text-muted-foreground text-xs">{group.name}:</span>
10481
+ {group.values.map((value) => {
10482
+ const isSelected = selectedAttrs[group.name] === value;
10483
+ const variantForValue = variants?.find((v) => {
10484
+ const opts = getVariantOptions(v as any);
10485
+ const matchesValue = opts.some((o) => o.name === group.name && o.value === value);
10486
+ if (!matchesValue) return false;
10487
+ return Object.entries(selectedAttrs).every(([k, sv]) => {
10488
+ if (k === group.name) return true;
10489
+ return opts.some((o) => o.name === k && o.value === sv);
10490
+ });
10491
+ });
10492
+ const isVariantOos =
10493
+ variantForValue?.inventory?.trackingMode !== 'NOT_TRACKED' &&
10494
+ variantForValue?.inventory?.available != null &&
10495
+ variantForValue.inventory.available <= 0;
10496
+
10497
+ return (
10498
+ <button
10499
+ key={value}
10500
+ type="button"
10501
+ onClick={() => handleAttrSelect(group.name, value)}
10502
+ disabled={isVariantOos}
10503
+ className={cn(
10504
+ 'rounded-full border px-2.5 py-0.5 text-xs transition-colors',
10505
+ isSelected
10506
+ ? 'border-primary bg-primary text-primary-foreground'
10507
+ : 'border-border text-foreground hover:border-primary/50',
10508
+ isVariantOos && 'cursor-not-allowed line-through opacity-40'
10509
+ )}
10510
+ >
10511
+ {value}
10512
+ </button>
10513
+ );
10514
+ })}
10515
+ </div>
10516
+ ))}
10517
+ {isOos && effectiveVariantId && (
10518
+ <p className="text-destructive text-xs">{t('outOfStock') || 'Out of stock'}</p>
10519
+ )}
10520
+ </div>
10521
+ )}
10074
10522
  </div>
10075
10523
  );
10076
10524
  }
10077
10525
  `,
10078
10526
  "src/components/checkout/order-bump-card.tsx": `'use client';
10079
10527
 
10528
+ import { useState, useMemo } from 'react';
10080
10529
  import Image from 'next/image';
10081
- import type { OrderBump } from 'brainerce';
10082
- import { formatPrice } from 'brainerce';
10530
+ import type { OrderBump, RecommendationVariant } from 'brainerce';
10531
+ import { formatPrice, getVariantOptions } from 'brainerce';
10083
10532
  import { useStoreInfo } from '@/providers/store-provider';
10084
10533
  import { useTranslations } from '@/lib/translations';
10085
10534
  import { cn } from '@/lib/utils';
@@ -10087,7 +10536,7 @@ import { cn } from '@/lib/utils';
10087
10536
  interface OrderBumpCardProps {
10088
10537
  bump: OrderBump;
10089
10538
  isAdded: boolean;
10090
- onToggle: (bumpId: string, add: boolean) => void;
10539
+ onToggle: (bumpId: string, add: boolean, variantId?: string) => void;
10091
10540
  loading: boolean;
10092
10541
  className?: string;
10093
10542
  }
@@ -10096,66 +10545,225 @@ export function OrderBumpCard({ bump, isAdded, onToggle, loading, className }: O
10096
10545
  const { storeInfo } = useStoreInfo();
10097
10546
  const t = useTranslations('checkout');
10098
10547
  const currency = storeInfo?.currency || 'USD';
10548
+ const [selectedVariantId, setSelectedVariantId] = useState<string | null>(null);
10099
10549
 
10100
10550
  const product = bump.bumpProduct;
10551
+ const variants = product.variants;
10552
+ const requiresSelection = bump.requiresVariantSelection && variants && variants.length > 0;
10553
+
10554
+ // Build attribute groups from variants for pill selector
10555
+ const attributeGroups = useMemo(() => {
10556
+ if (!requiresSelection || !variants) return [];
10557
+ const groups = new Map<string, Set<string>>();
10558
+ for (const v of variants) {
10559
+ const opts = getVariantOptions(v as any);
10560
+ for (const opt of opts) {
10561
+ if (!groups.has(opt.name)) groups.set(opt.name, new Set());
10562
+ groups.get(opt.name)!.add(opt.value);
10563
+ }
10564
+ }
10565
+ return Array.from(groups.entries()).map(([name, values]) => ({
10566
+ name,
10567
+ values: Array.from(values),
10568
+ }));
10569
+ }, [requiresSelection, variants]);
10570
+
10571
+ // Track selected attributes
10572
+ const [selectedAttrs, setSelectedAttrs] = useState<Record<string, string>>({});
10573
+
10574
+ // Find matching variant based on selected attributes
10575
+ const selectedVariant = useMemo(() => {
10576
+ if (!requiresSelection || !variants) return null;
10577
+ return (
10578
+ variants.find((v) => {
10579
+ const opts = getVariantOptions(v as any);
10580
+ return attributeGroups.every((group) => {
10581
+ const opt = opts.find((o) => o.name === group.name);
10582
+ return opt && selectedAttrs[group.name] === opt.value;
10583
+ });
10584
+ }) ?? null
10585
+ );
10586
+ }, [requiresSelection, variants, selectedAttrs, attributeGroups]);
10587
+
10588
+ // Update selectedVariantId when variant match changes
10589
+ const effectiveVariantId = selectedVariant?.id ?? selectedVariantId;
10590
+
10591
+ function handleAttrSelect(attrName: string, value: string) {
10592
+ const next = { ...selectedAttrs, [attrName]: value };
10593
+ setSelectedAttrs(next);
10594
+ // Find matching variant with new selection
10595
+ if (variants) {
10596
+ const match = variants.find((v) => {
10597
+ const opts = getVariantOptions(v as any);
10598
+ return attributeGroups.every((group) => {
10599
+ const opt = opts.find((o) => o.name === group.name);
10600
+ return opt && next[group.name] === opt.value;
10601
+ });
10602
+ });
10603
+ setSelectedVariantId(match?.id ?? null);
10604
+ }
10605
+ }
10606
+
10607
+ // Compute display price
10608
+ const { displayOriginal, displayDiscounted } = useMemo(() => {
10609
+ let effectivePrice: number;
10610
+ if (selectedVariant) {
10611
+ const vSale = selectedVariant.salePrice ? parseFloat(selectedVariant.salePrice) : null;
10612
+ const vPrice = selectedVariant.price ? parseFloat(selectedVariant.price) : null;
10613
+ effectivePrice = vSale ?? vPrice ?? parseFloat(bump.originalPrice);
10614
+ } else {
10615
+ effectivePrice = parseFloat(bump.originalPrice);
10616
+ }
10617
+
10618
+ let discounted: number | null = null;
10619
+ if (bump.discountType && bump.discountValue) {
10620
+ const dv = parseFloat(bump.discountValue);
10621
+ if (bump.discountType === 'PERCENTAGE') {
10622
+ discounted = effectivePrice * (1 - dv / 100);
10623
+ } else {
10624
+ discounted = Math.max(0, effectivePrice - dv);
10625
+ }
10626
+ }
10627
+
10628
+ return { displayOriginal: effectivePrice, displayDiscounted: discounted };
10629
+ }, [selectedVariant, bump.originalPrice, bump.discountType, bump.discountValue]);
10630
+
10631
+ // Check if selected variant is out of stock
10632
+ const isOos =
10633
+ selectedVariant?.inventory?.trackingMode !== 'NOT_TRACKED' &&
10634
+ selectedVariant?.inventory?.available != null &&
10635
+ selectedVariant.inventory.available <= 0;
10636
+
10637
+ // Locked variant label
10638
+ const lockedLabel =
10639
+ bump.lockedVariant?.name ??
10640
+ (bump.lockedVariant?.attributes
10641
+ ? Object.values(bump.lockedVariant.attributes).join(' / ')
10642
+ : null);
10643
+
10101
10644
  const firstImage = product.images?.[0];
10102
10645
  const imageUrl = firstImage
10103
10646
  ? typeof firstImage === 'string'
10104
10647
  ? firstImage
10105
10648
  : firstImage.url
10106
10649
  : null;
10107
- const originalPrice = parseFloat(bump.originalPrice);
10108
- const hasDiscount = bump.discountedPrice != null;
10109
- const discountedPrice = hasDiscount ? parseFloat(bump.discountedPrice!) : null;
10650
+
10651
+ const canToggle = !requiresSelection || !!effectiveVariantId;
10110
10652
 
10111
10653
  return (
10112
- <label
10654
+ <div
10113
10655
  className={cn(
10114
- 'border-border hover:border-primary/50 flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition-colors',
10656
+ 'border-border hover:border-primary/50 rounded-lg border p-3 transition-colors',
10115
10657
  isAdded && 'border-primary bg-primary/5',
10116
10658
  loading && 'pointer-events-none opacity-60',
10117
10659
  className
10118
10660
  )}
10119
10661
  >
10120
- <input
10121
- type="checkbox"
10122
- checked={isAdded}
10123
- onChange={() => onToggle(bump.id, !isAdded)}
10124
- disabled={loading}
10125
- className="mt-1 h-4 w-4 shrink-0 rounded"
10126
- />
10127
-
10128
- {/* Image */}
10129
- {imageUrl && (
10130
- <div className="bg-muted relative h-10 w-10 shrink-0 overflow-hidden rounded">
10131
- <Image src={imageUrl} alt={product.name} fill sizes="40px" className="object-cover" />
10132
- </div>
10133
- )}
10662
+ <label className="flex cursor-pointer items-start gap-3">
10663
+ <input
10664
+ type="checkbox"
10665
+ checked={isAdded}
10666
+ onChange={() => {
10667
+ if (canToggle) {
10668
+ onToggle(bump.id, !isAdded, effectiveVariantId ?? undefined);
10669
+ }
10670
+ }}
10671
+ disabled={loading || !canToggle || isOos}
10672
+ className="mt-1 h-4 w-4 shrink-0 rounded"
10673
+ />
10134
10674
 
10135
- {/* Content */}
10136
- <div className="min-w-0 flex-1">
10137
- <p className="text-foreground text-sm font-medium">{bump.title}</p>
10138
- {bump.description && (
10139
- <p className="text-muted-foreground mt-0.5 text-xs">{bump.description}</p>
10675
+ {/* Image */}
10676
+ {imageUrl && (
10677
+ <div className="bg-muted relative h-10 w-10 shrink-0 overflow-hidden rounded">
10678
+ <Image src={imageUrl} alt={product.name} fill sizes="40px" className="object-cover" />
10679
+ </div>
10140
10680
  )}
10141
- <div className="mt-1 flex items-center gap-2">
10142
- {hasDiscount ? (
10143
- <>
10144
- <span className="text-muted-foreground text-xs line-through">
10145
- {formatPrice(originalPrice, { currency }) as string}
10146
- </span>
10681
+
10682
+ {/* Content */}
10683
+ <div className="min-w-0 flex-1">
10684
+ <p className="text-foreground text-sm font-medium">{bump.title}</p>
10685
+ {bump.description && (
10686
+ <p className="text-muted-foreground mt-0.5 text-xs">{bump.description}</p>
10687
+ )}
10688
+
10689
+ {/* Locked variant label */}
10690
+ {lockedLabel && <p className="text-muted-foreground mt-0.5 text-xs">{lockedLabel}</p>}
10691
+
10692
+ {/* Price */}
10693
+ <div className="mt-1 flex items-center gap-2">
10694
+ {displayDiscounted != null ? (
10695
+ <>
10696
+ <span className="text-muted-foreground text-xs line-through">
10697
+ {formatPrice(displayOriginal, { currency }) as string}
10698
+ </span>
10699
+ <span className="text-foreground text-sm font-semibold">
10700
+ {formatPrice(displayDiscounted, { currency }) as string}
10701
+ </span>
10702
+ </>
10703
+ ) : (
10147
10704
  <span className="text-foreground text-sm font-semibold">
10148
- {formatPrice(discountedPrice!, { currency }) as string}
10705
+ {formatPrice(displayOriginal, { currency }) as string}
10149
10706
  </span>
10150
- </>
10151
- ) : (
10152
- <span className="text-foreground text-sm font-semibold">
10153
- {formatPrice(originalPrice, { currency }) as string}
10154
- </span>
10707
+ )}
10708
+ {requiresSelection && !effectiveVariantId && (
10709
+ <span className="text-muted-foreground text-xs">
10710
+ {t('selectOptions') || 'Select options'}
10711
+ </span>
10712
+ )}
10713
+ </div>
10714
+ </div>
10715
+ </label>
10716
+
10717
+ {/* Compact variant selector */}
10718
+ {requiresSelection && !isAdded && (
10719
+ <div className="ms-7 mt-2 space-y-1.5">
10720
+ {attributeGroups.map((group) => (
10721
+ <div key={group.name} className="flex flex-wrap items-center gap-1.5">
10722
+ <span className="text-muted-foreground text-xs">{group.name}:</span>
10723
+ {group.values.map((value) => {
10724
+ const isSelected = selectedAttrs[group.name] === value;
10725
+ // Check if this value leads to any available variant
10726
+ const variantForValue = variants?.find((v) => {
10727
+ const opts = getVariantOptions(v as any);
10728
+ const matchesValue = opts.some((o) => o.name === group.name && o.value === value);
10729
+ if (!matchesValue) return false;
10730
+ // Check other selected attrs
10731
+ return Object.entries(selectedAttrs).every(([k, sv]) => {
10732
+ if (k === group.name) return true;
10733
+ return opts.some((o) => o.name === k && o.value === sv);
10734
+ });
10735
+ });
10736
+ const isVariantOos =
10737
+ variantForValue?.inventory?.trackingMode !== 'NOT_TRACKED' &&
10738
+ variantForValue?.inventory?.available != null &&
10739
+ variantForValue.inventory.available <= 0;
10740
+
10741
+ return (
10742
+ <button
10743
+ key={value}
10744
+ type="button"
10745
+ onClick={() => handleAttrSelect(group.name, value)}
10746
+ disabled={isVariantOos}
10747
+ className={cn(
10748
+ 'rounded-full border px-2.5 py-0.5 text-xs transition-colors',
10749
+ isSelected
10750
+ ? 'border-primary bg-primary text-primary-foreground'
10751
+ : 'border-border text-foreground hover:border-primary/50',
10752
+ isVariantOos && 'cursor-not-allowed line-through opacity-40'
10753
+ )}
10754
+ >
10755
+ {value}
10756
+ </button>
10757
+ );
10758
+ })}
10759
+ </div>
10760
+ ))}
10761
+ {isOos && effectiveVariantId && (
10762
+ <p className="text-destructive text-xs">{t('outOfStock') || 'Out of stock'}</p>
10155
10763
  )}
10156
10764
  </div>
10157
- </div>
10158
- </label>
10765
+ )}
10766
+ </div>
10159
10767
  );
10160
10768
  }
10161
10769
  `,
@@ -10599,46 +11207,39 @@ async function handleGetRequiredPages(args) {
10599
11207
  import { z as z5 } from "zod";
10600
11208
 
10601
11209
  // src/utils/fetch-store-info.ts
10602
- import https from "https";
10603
- import http from "http";
10604
11210
  async function fetchStoreInfo(connectionId, baseUrl = "https://api.brainerce.com") {
10605
11211
  const url = `${baseUrl}/api/vc/${connectionId}/info`;
10606
- return new Promise((resolve, reject) => {
10607
- const client = url.startsWith("https") ? https : http;
10608
- const req = client.get(url, { timeout: 1e4 }, (res) => {
10609
- let data = "";
10610
- res.on("data", (chunk) => {
10611
- data += chunk;
10612
- });
10613
- res.on("end", () => {
10614
- if (res.statusCode && res.statusCode >= 400) {
10615
- if (res.statusCode === 404) {
10616
- reject(new Error(`Connection ID "${connectionId}" not found. Check your dashboard.`));
10617
- } else {
10618
- reject(new Error(`API returned status ${res.statusCode}`));
10619
- }
10620
- return;
10621
- }
10622
- try {
10623
- const json = JSON.parse(data);
10624
- resolve({
10625
- name: json.name || json.storeName || "My Store",
10626
- currency: json.currency || "USD",
10627
- language: json.language || "en"
10628
- });
10629
- } catch {
10630
- reject(new Error("Invalid response from API"));
10631
- }
10632
- });
10633
- });
10634
- req.on("error", (err) => {
10635
- reject(new Error(`Failed to connect to Brainerce API: ${err.message}`));
10636
- });
10637
- req.on("timeout", () => {
10638
- req.destroy();
10639
- reject(new Error("Request timed out"));
10640
- });
10641
- });
11212
+ const controller = new AbortController();
11213
+ const timeout = setTimeout(() => controller.abort(), 1e4);
11214
+ let res;
11215
+ try {
11216
+ res = await fetch(url, { signal: controller.signal });
11217
+ } catch (err) {
11218
+ if (err.name === "AbortError") {
11219
+ throw new Error("Request timed out");
11220
+ }
11221
+ throw new Error(`Failed to connect to Brainerce API: ${err.message}`);
11222
+ } finally {
11223
+ clearTimeout(timeout);
11224
+ }
11225
+ if (res.status === 404) {
11226
+ throw new Error(`Connection ID "${connectionId}" not found. Check your dashboard.`);
11227
+ }
11228
+ if (!res.ok) {
11229
+ throw new Error(`API returned status ${res.status}`);
11230
+ }
11231
+ let json;
11232
+ try {
11233
+ json = await res.json();
11234
+ } catch {
11235
+ throw new Error("Invalid response from API");
11236
+ }
11237
+ return {
11238
+ name: json.name || json.storeName || "My Store",
11239
+ currency: json.currency || "USD",
11240
+ language: json.language || "en",
11241
+ ...json.i18n ? { i18n: json.i18n } : {}
11242
+ };
10642
11243
  }
10643
11244
 
10644
11245
  // src/tools/get-store-info.ts
@@ -10649,7 +11250,11 @@ var GET_STORE_INFO_SCHEMA = {
10649
11250
  };
10650
11251
  async function handleGetStoreInfo(args) {
10651
11252
  try {
10652
- const info = await fetchStoreInfo(args.connectionId);
11253
+ const baseUrl = (process.env.BRAINERCE_API_URL || "https://api.brainerce.com").replace(
11254
+ /\/$/,
11255
+ ""
11256
+ );
11257
+ const info = await fetchStoreInfo(args.connectionId, baseUrl);
10653
11258
  return {
10654
11259
  content: [
10655
11260
  {
@@ -10680,41 +11285,32 @@ async function handleGetStoreInfo(args) {
10680
11285
  import { z as z6 } from "zod";
10681
11286
 
10682
11287
  // src/utils/fetch-store-capabilities.ts
10683
- import https2 from "https";
10684
- import http2 from "http";
10685
11288
  async function fetchStoreCapabilities(connectionId, baseUrl = "https://api.brainerce.com") {
10686
11289
  const url = `${baseUrl}/api/vc/${connectionId}/capabilities`;
10687
- return new Promise((resolve, reject) => {
10688
- const client = url.startsWith("https") ? https2 : http2;
10689
- const req = client.get(url, { timeout: 1e4 }, (res) => {
10690
- let data = "";
10691
- res.on("data", (chunk) => {
10692
- data += chunk;
10693
- });
10694
- res.on("end", () => {
10695
- if (res.statusCode && res.statusCode >= 400) {
10696
- if (res.statusCode === 404) {
10697
- reject(new Error(`Connection ID "${connectionId}" not found. Check your dashboard.`));
10698
- } else {
10699
- reject(new Error(`API returned status ${res.statusCode}`));
10700
- }
10701
- return;
10702
- }
10703
- try {
10704
- resolve(JSON.parse(data));
10705
- } catch {
10706
- reject(new Error("Invalid response from API"));
10707
- }
10708
- });
10709
- });
10710
- req.on("error", (err) => {
10711
- reject(new Error(`Failed to connect to Brainerce API: ${err.message}`));
10712
- });
10713
- req.on("timeout", () => {
10714
- req.destroy();
10715
- reject(new Error("Request timed out"));
10716
- });
10717
- });
11290
+ const controller = new AbortController();
11291
+ const timeout = setTimeout(() => controller.abort(), 1e4);
11292
+ let res;
11293
+ try {
11294
+ res = await fetch(url, { signal: controller.signal });
11295
+ } catch (err) {
11296
+ if (err.name === "AbortError") {
11297
+ throw new Error("Request timed out");
11298
+ }
11299
+ throw new Error(`Failed to connect to Brainerce API: ${err.message}`);
11300
+ } finally {
11301
+ clearTimeout(timeout);
11302
+ }
11303
+ if (res.status === 404) {
11304
+ throw new Error(`Connection ID "${connectionId}" not found. Check your dashboard.`);
11305
+ }
11306
+ if (!res.ok) {
11307
+ throw new Error(`API returned status ${res.status}`);
11308
+ }
11309
+ try {
11310
+ return await res.json();
11311
+ } catch {
11312
+ throw new Error("Invalid response from API");
11313
+ }
10718
11314
  }
10719
11315
 
10720
11316
  // src/tools/get-store-capabilities.ts
@@ -10727,6 +11323,11 @@ function formatCapabilities(caps) {
10727
11323
  const lines = [];
10728
11324
  lines.push(`## Store: ${caps.store.name} (${caps.store.currency})`);
10729
11325
  lines.push(`Language: ${caps.store.language}`);
11326
+ if (caps.store.i18n?.enabled) {
11327
+ lines.push(
11328
+ `Multi-language: ENABLED \u2014 locales: ${caps.store.i18n.supportedLocales.join(", ")} (default: ${caps.store.i18n.defaultLocale})`
11329
+ );
11330
+ }
10730
11331
  lines.push("");
10731
11332
  lines.push("## Configured Features");
10732
11333
  if (caps.features.paymentProviders.length > 0) {
@@ -10813,6 +11414,11 @@ function formatCapabilities(caps) {
10813
11414
  'Inventory reservation is active \u2014 show a countdown timer. Use get-code-example("reservation-countdown").'
10814
11415
  );
10815
11416
  }
11417
+ if (caps.store.i18n?.enabled && caps.store.i18n.supportedLocales.length > 1) {
11418
+ suggestions.push(
11419
+ `Multi-language is enabled (${caps.store.i18n.supportedLocales.join(", ")}). Use client.setLocale(locale) before fetching content. Consider locale-prefixed routes (/he/products, /en/products) and a language switcher in the header.`
11420
+ );
11421
+ }
10816
11422
  if (suggestions.length === 0) {
10817
11423
  suggestions.push(
10818
11424
  "Start by building the required pages. Use get-required-pages() for the checklist."
@@ -10823,7 +11429,11 @@ function formatCapabilities(caps) {
10823
11429
  }
10824
11430
  async function handleGetStoreCapabilities(args) {
10825
11431
  try {
10826
- const caps = await fetchStoreCapabilities(args.connectionId);
11432
+ const baseUrl = (process.env.BRAINERCE_API_URL || "https://api.brainerce.com").replace(
11433
+ /\/$/,
11434
+ ""
11435
+ );
11436
+ const caps = await fetchStoreCapabilities(args.connectionId, baseUrl);
10827
11437
  return {
10828
11438
  content: [{ type: "text", text: formatCapabilities(caps) }]
10829
11439
  };
@@ -10920,6 +11530,20 @@ function formatCapabilitiesSummary(caps) {
10920
11530
  const lines = [];
10921
11531
  lines.push(`## Store: ${caps.store.name}`);
10922
11532
  lines.push(`Currency: ${caps.store.currency} | Language: ${caps.store.language}`);
11533
+ if (caps.store.i18n?.enabled && caps.store.i18n.supportedLocales.length > 1) {
11534
+ lines.push(
11535
+ `Multi-language: ${caps.store.i18n.supportedLocales.join(", ")} (default: ${caps.store.i18n.defaultLocale})`
11536
+ );
11537
+ lines.push("");
11538
+ lines.push("### i18n Implementation");
11539
+ lines.push(
11540
+ "- Call `client.setLocale(locale)` at app init based on URL prefix or user preference"
11541
+ );
11542
+ lines.push("- All getProducts/getCategories/getBrands calls auto-include the locale");
11543
+ lines.push("- Use locale-prefixed routes: `/[locale]/products`, `/[locale]/products/[slug]`");
11544
+ lines.push("- Add a language switcher component in the header");
11545
+ lines.push('- RTL locales (he, ar): set `<html dir="rtl">` \u2014 flexbox reversal is automatic');
11546
+ }
10923
11547
  lines.push("");
10924
11548
  lines.push("### Configured Features");
10925
11549
  if (caps.features.paymentProviders.length > 0) {
@@ -10973,8 +11597,7 @@ function buildStoreBundle(options) {
10973
11597
  connectionId,
10974
11598
  storeName,
10975
11599
  currency,
10976
- language: capabilities?.store.language || "en",
10977
- apiUrl: "https://api.brainerce.com"
11600
+ language: capabilities?.store.language || "en"
10978
11601
  };
10979
11602
  const sections = [];
10980
11603
  sections.push(`# Build: ${storeName} \u2014 ${storeType}${styleDesc}
@@ -11075,9 +11698,13 @@ var BUILD_STORE_SCHEMA = {
11075
11698
  storeStyle: z7.string().optional().describe('Design style (e.g., "minimalist", "luxury", "playful", "dark mode")')
11076
11699
  };
11077
11700
  async function handleBuildStore(args) {
11701
+ const baseUrl = (process.env.BRAINERCE_API_URL || "https://api.brainerce.com").replace(
11702
+ /\/$/,
11703
+ ""
11704
+ );
11078
11705
  let capabilities = null;
11079
11706
  try {
11080
- capabilities = await fetchStoreCapabilities(args.connectionId);
11707
+ capabilities = await fetchStoreCapabilities(args.connectionId, baseUrl);
11081
11708
  } catch {
11082
11709
  }
11083
11710
  const bundle = buildStoreBundle({
@@ -11416,6 +12043,57 @@ ${results.join("\n\n---\n\n")}`
11416
12043
  };
11417
12044
  }
11418
12045
 
12046
+ // src/tools/get-integration-guide.ts
12047
+ import { z as z10 } from "zod";
12048
+ var GET_INTEGRATION_GUIDE_NAME = "get-integration-guide";
12049
+ var GET_INTEGRATION_GUIDE_DESCRIPTION = 'Get the Brainerce integration guide for connecting any website to Brainerce. Returns step-by-step instructions with full API endpoints, request/response examples, and code snippets. Use "core" for the main guide (products, cart, checkout, payment, orders), "optional" for extra features (accounts, OAuth, promotions), or "rules" for validation, error codes, and edge cases.';
12050
+ var GET_INTEGRATION_GUIDE_SCHEMA = {
12051
+ part: z10.enum(["core", "optional", "rules"]).describe(
12052
+ 'Which part of the integration guide to retrieve. "core" = products, cart, checkout, payment, orders (start here). "optional" = customer accounts, OAuth, discounts, bundles, downloads. "rules" = validation, error codes, edge cases, decision trees, common mistakes.'
12053
+ )
12054
+ };
12055
+ var GUIDE_URLS = {
12056
+ core: "https://brainerce.com/docs/integration/raw?part=core",
12057
+ optional: "https://brainerce.com/docs/integration/raw?part=optional",
12058
+ rules: "https://brainerce.com/docs/integration/raw?part=rules"
12059
+ };
12060
+ async function handleGetIntegrationGuide(args) {
12061
+ const url = GUIDE_URLS[args.part];
12062
+ if (!url) {
12063
+ return {
12064
+ content: [
12065
+ {
12066
+ type: "text",
12067
+ text: `Unknown part: "${args.part}". Available parts: core, optional, rules.`
12068
+ }
12069
+ ]
12070
+ };
12071
+ }
12072
+ try {
12073
+ const response = await fetch(url, {
12074
+ headers: { Accept: "text/plain" },
12075
+ signal: AbortSignal.timeout(15e3)
12076
+ });
12077
+ if (!response.ok) {
12078
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
12079
+ }
12080
+ const content = await response.text();
12081
+ return {
12082
+ content: [{ type: "text", text: content }]
12083
+ };
12084
+ } catch (error) {
12085
+ const message = error instanceof Error ? error.message : "Unknown error";
12086
+ return {
12087
+ content: [
12088
+ {
12089
+ type: "text",
12090
+ text: `Failed to fetch integration guide (${args.part}): ${message}. You can read it directly at: ${url}`
12091
+ }
12092
+ ]
12093
+ };
12094
+ }
12095
+ }
12096
+
11419
12097
  // src/resources/sdk-types.ts
11420
12098
  var SDK_TYPES_URI = "brainerce://sdk/types";
11421
12099
  var SDK_TYPES_NAME = "Brainerce SDK Types";
@@ -11570,15 +12248,15 @@ async function handleProjectTemplate(uri) {
11570
12248
  }
11571
12249
 
11572
12250
  // src/prompts/create-store.ts
11573
- import { z as z10 } from "zod";
12251
+ import { z as z11 } from "zod";
11574
12252
  var CREATE_STORE_NAME = "create-store";
11575
12253
  var CREATE_STORE_DESCRIPTION = "Generate a personalized prompt for building a complete Brainerce e-commerce store. Instructs the AI to call build-store for all templates and validate-store when done.";
11576
12254
  var CREATE_STORE_SCHEMA = {
11577
- connectionId: z10.string().describe("Vibe-coded connection ID (starts with vc_)"),
11578
- storeName: z10.string().describe('Name of the store (e.g., "Urban Threads")'),
11579
- storeType: z10.string().describe('Type of store (e.g., "Clothing store", "Electronics shop", "Bakery")'),
11580
- currency: z10.string().optional().default("USD").describe("Store currency code (e.g., USD, ILS, EUR)"),
11581
- storeStyle: z10.string().optional().describe('Design style (e.g., "minimalist", "luxury", "playful", "dark mode")')
12255
+ connectionId: z11.string().describe("Vibe-coded connection ID (starts with vc_)"),
12256
+ storeName: z11.string().describe('Name of the store (e.g., "Urban Threads")'),
12257
+ storeType: z11.string().describe('Type of store (e.g., "Clothing store", "Electronics shop", "Bakery")'),
12258
+ currency: z11.string().optional().default("USD").describe("Store currency code (e.g., USD, ILS, EUR)"),
12259
+ storeStyle: z11.string().optional().describe('Design style (e.g., "minimalist", "luxury", "playful", "dark mode")')
11582
12260
  };
11583
12261
  function handleCreateStore(args) {
11584
12262
  const styleDesc = args.storeStyle ? ` with a ${args.storeStyle} design style` : "";
@@ -11617,6 +12295,13 @@ DO NOT STOP until all 13 pages + header are implemented:
11617
12295
  13. \`/account\` \u2014 Account dashboard
11618
12296
  14. Header component with cart count + search
11619
12297
 
12298
+ ## Multi-Language
12299
+ If the store has multi-language enabled (check build-store output), implement:
12300
+ - Locale-prefixed routes: /[locale]/products instead of /products
12301
+ - Call client.setLocale(locale) based on URL prefix
12302
+ - Language switcher in header
12303
+ - dir="rtl" on <html> for RTL locales (he, ar)
12304
+
11620
12305
  ## Step 3: Validate
11621
12306
  After building all pages, call \`validate-store\` with the list of files you created to verify nothing is missing.
11622
12307
 
@@ -11635,11 +12320,11 @@ After building all pages, call \`validate-store\` with the list of files you cre
11635
12320
  }
11636
12321
 
11637
12322
  // src/prompts/add-feature.ts
11638
- import { z as z11 } from "zod";
12323
+ import { z as z12 } from "zod";
11639
12324
  var ADD_FEATURE_NAME = "add-feature";
11640
12325
  var ADD_FEATURE_DESCRIPTION = "Generate a focused prompt for adding a specific feature to an existing Brainerce store. Returns targeted SDK docs, types, and code examples for just that feature.";
11641
12326
  var ADD_FEATURE_SCHEMA = {
11642
- feature: z11.enum([
12327
+ feature: z12.enum([
11643
12328
  "products",
11644
12329
  "cart",
11645
12330
  "checkout",
@@ -11652,8 +12337,8 @@ var ADD_FEATURE_SCHEMA = {
11652
12337
  "search",
11653
12338
  "tax"
11654
12339
  ]).describe("The feature to add"),
11655
- connectionId: z11.string().optional().describe("Vibe-coded connection ID (starts with vc_)"),
11656
- currency: z11.string().optional().default("USD").describe("Store currency code")
12340
+ connectionId: z12.string().optional().describe("Vibe-coded connection ID (starts with vc_)"),
12341
+ currency: z12.string().optional().default("USD").describe("Store currency code")
11657
12342
  };
11658
12343
  var FEATURE_TO_TOPICS = {
11659
12344
  products: { topics: ["products"], domains: ["products"] },
@@ -11768,6 +12453,12 @@ function createServer() {
11768
12453
  GET_PAGE_CODE_SCHEMA,
11769
12454
  handleGetPageCode
11770
12455
  );
12456
+ server.tool(
12457
+ GET_INTEGRATION_GUIDE_NAME,
12458
+ GET_INTEGRATION_GUIDE_DESCRIPTION,
12459
+ GET_INTEGRATION_GUIDE_SCHEMA,
12460
+ handleGetIntegrationGuide
12461
+ );
11771
12462
  server.resource(
11772
12463
  SDK_TYPES_NAME,
11773
12464
  SDK_TYPES_URI,