@brainerce/mcp-server 2.4.0 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/http.js +970 -236
- package/dist/bin/stdio.js +970 -236
- package/dist/index.d.mts +10 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +970 -224
- package/dist/index.mjs +970 -214
- package/package.json +3 -2
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":
|
|
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":
|
|
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
|
|
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
|
-
|
|
7658
|
-
|
|
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
|
-
|
|
7834
|
-
|
|
7835
|
-
|
|
7836
|
-
className="w-full
|
|
7837
|
-
|
|
7838
|
-
|
|
7839
|
-
|
|
7840
|
-
|
|
7841
|
-
|
|
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
|
-
|
|
9997
|
-
const
|
|
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=
|
|
10021
|
-
|
|
10022
|
-
className
|
|
10023
|
-
|
|
10024
|
-
|
|
10025
|
-
|
|
10026
|
-
|
|
10027
|
-
|
|
10028
|
-
|
|
10029
|
-
|
|
10030
|
-
|
|
10031
|
-
|
|
10032
|
-
|
|
10033
|
-
|
|
10034
|
-
|
|
10035
|
-
|
|
10036
|
-
|
|
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
|
-
|
|
10044
|
-
|
|
10045
|
-
|
|
10046
|
-
|
|
10047
|
-
|
|
10048
|
-
|
|
10049
|
-
|
|
10050
|
-
<
|
|
10051
|
-
|
|
10052
|
-
|
|
10053
|
-
|
|
10054
|
-
|
|
10055
|
-
|
|
10056
|
-
|
|
10057
|
-
-
|
|
10058
|
-
|
|
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
|
-
{/*
|
|
10063
|
-
|
|
10064
|
-
|
|
10065
|
-
|
|
10066
|
-
|
|
10067
|
-
|
|
10068
|
-
|
|
10069
|
-
|
|
10070
|
-
|
|
10071
|
-
|
|
10072
|
-
|
|
10073
|
-
|
|
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
|
-
|
|
10108
|
-
const
|
|
10109
|
-
const discountedPrice = hasDiscount ? parseFloat(bump.discountedPrice!) : null;
|
|
10650
|
+
|
|
10651
|
+
const canToggle = !requiresSelection || !!effectiveVariantId;
|
|
10110
10652
|
|
|
10111
10653
|
return (
|
|
10112
|
-
<
|
|
10654
|
+
<div
|
|
10113
10655
|
className={cn(
|
|
10114
|
-
'border-border hover:border-primary/50
|
|
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
|
-
<
|
|
10121
|
-
|
|
10122
|
-
|
|
10123
|
-
|
|
10124
|
-
|
|
10125
|
-
|
|
10126
|
-
|
|
10127
|
-
|
|
10128
|
-
|
|
10129
|
-
|
|
10130
|
-
|
|
10131
|
-
|
|
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
|
-
|
|
10136
|
-
|
|
10137
|
-
|
|
10138
|
-
|
|
10139
|
-
|
|
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
|
-
|
|
10142
|
-
|
|
10143
|
-
|
|
10144
|
-
|
|
10145
|
-
|
|
10146
|
-
|
|
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(
|
|
10705
|
+
{formatPrice(displayOriginal, { currency }) as string}
|
|
10149
10706
|
</span>
|
|
10150
|
-
|
|
10151
|
-
|
|
10152
|
-
|
|
10153
|
-
|
|
10154
|
-
|
|
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
|
-
|
|
10158
|
-
</
|
|
10765
|
+
)}
|
|
10766
|
+
</div>
|
|
10159
10767
|
);
|
|
10160
10768
|
}
|
|
10161
10769
|
`,
|
|
@@ -10599,46 +11207,81 @@ async function handleGetRequiredPages(args) {
|
|
|
10599
11207
|
import { z as z5 } from "zod";
|
|
10600
11208
|
|
|
10601
11209
|
// src/utils/fetch-store-info.ts
|
|
10602
|
-
|
|
10603
|
-
|
|
10604
|
-
|
|
11210
|
+
var KNOWN_API_URLS = {
|
|
11211
|
+
production: "https://api.brainerce.com",
|
|
11212
|
+
staging: "https://api-staging.brainerce.com"
|
|
11213
|
+
};
|
|
11214
|
+
var ConnectionNotFoundError = class extends Error {
|
|
11215
|
+
constructor(connectionId, baseUrl) {
|
|
11216
|
+
super(`Connection "${connectionId}" not found at ${baseUrl}`);
|
|
11217
|
+
this.code = "NOT_FOUND";
|
|
11218
|
+
}
|
|
11219
|
+
};
|
|
11220
|
+
async function fetchStoreInfo(connectionId, baseUrl = KNOWN_API_URLS.production) {
|
|
10605
11221
|
const url = `${baseUrl}/api/vc/${connectionId}/info`;
|
|
10606
|
-
|
|
10607
|
-
|
|
10608
|
-
|
|
10609
|
-
|
|
10610
|
-
|
|
10611
|
-
|
|
10612
|
-
|
|
10613
|
-
|
|
10614
|
-
|
|
10615
|
-
|
|
10616
|
-
|
|
10617
|
-
|
|
10618
|
-
|
|
10619
|
-
|
|
10620
|
-
|
|
10621
|
-
|
|
10622
|
-
|
|
10623
|
-
|
|
10624
|
-
|
|
10625
|
-
|
|
10626
|
-
|
|
10627
|
-
|
|
10628
|
-
|
|
10629
|
-
|
|
10630
|
-
|
|
10631
|
-
|
|
10632
|
-
|
|
10633
|
-
|
|
10634
|
-
|
|
10635
|
-
|
|
10636
|
-
|
|
10637
|
-
|
|
10638
|
-
|
|
10639
|
-
|
|
10640
|
-
|
|
10641
|
-
|
|
11222
|
+
const controller = new AbortController();
|
|
11223
|
+
const timeout = setTimeout(() => controller.abort(), 1e4);
|
|
11224
|
+
let res;
|
|
11225
|
+
try {
|
|
11226
|
+
res = await fetch(url, { signal: controller.signal });
|
|
11227
|
+
} catch (err) {
|
|
11228
|
+
if (err.name === "AbortError") {
|
|
11229
|
+
throw new Error(`Request to ${baseUrl} timed out`);
|
|
11230
|
+
}
|
|
11231
|
+
throw new Error(`Failed to connect to ${baseUrl}: ${err.message}`);
|
|
11232
|
+
} finally {
|
|
11233
|
+
clearTimeout(timeout);
|
|
11234
|
+
}
|
|
11235
|
+
if (res.status === 404) {
|
|
11236
|
+
throw new ConnectionNotFoundError(connectionId, baseUrl);
|
|
11237
|
+
}
|
|
11238
|
+
if (!res.ok) {
|
|
11239
|
+
throw new Error(`${baseUrl} returned status ${res.status}`);
|
|
11240
|
+
}
|
|
11241
|
+
let json;
|
|
11242
|
+
try {
|
|
11243
|
+
json = await res.json();
|
|
11244
|
+
} catch {
|
|
11245
|
+
throw new Error(`Invalid response from ${baseUrl}`);
|
|
11246
|
+
}
|
|
11247
|
+
return {
|
|
11248
|
+
name: json.name || json.storeName || "My Store",
|
|
11249
|
+
currency: json.currency || "USD",
|
|
11250
|
+
language: json.language || "en",
|
|
11251
|
+
...json.i18n ? { i18n: json.i18n } : {}
|
|
11252
|
+
};
|
|
11253
|
+
}
|
|
11254
|
+
async function resolveStoreInfo(connectionId, candidateUrls) {
|
|
11255
|
+
const urls = candidateUrls.filter((u, i) => u && candidateUrls.indexOf(u) === i);
|
|
11256
|
+
if (urls.length === 0) {
|
|
11257
|
+
throw new Error("No API URLs to try");
|
|
11258
|
+
}
|
|
11259
|
+
let lastError;
|
|
11260
|
+
let allNotFound = true;
|
|
11261
|
+
for (let i = 0; i < urls.length; i++) {
|
|
11262
|
+
const baseUrl = urls[i];
|
|
11263
|
+
try {
|
|
11264
|
+
const info = await fetchStoreInfo(connectionId, baseUrl);
|
|
11265
|
+
return { info, apiBaseUrl: baseUrl, fellBack: i > 0 };
|
|
11266
|
+
} catch (err) {
|
|
11267
|
+
lastError = err;
|
|
11268
|
+
const isNotFound = err.code === "NOT_FOUND";
|
|
11269
|
+
if (!isNotFound) {
|
|
11270
|
+
allNotFound = false;
|
|
11271
|
+
}
|
|
11272
|
+
}
|
|
11273
|
+
}
|
|
11274
|
+
if (allNotFound) {
|
|
11275
|
+
throw new Error(
|
|
11276
|
+
`Connection "${connectionId}" not found in any known environment (${urls.join(", ")}).`
|
|
11277
|
+
);
|
|
11278
|
+
}
|
|
11279
|
+
throw lastError || new Error("Failed to resolve store info");
|
|
11280
|
+
}
|
|
11281
|
+
function getCandidateApiUrls() {
|
|
11282
|
+
const explicit = (process.env.BRAINERCE_API_URL || "").replace(/\/$/, "");
|
|
11283
|
+
if (explicit) return [explicit];
|
|
11284
|
+
return [KNOWN_API_URLS.production, KNOWN_API_URLS.staging];
|
|
10642
11285
|
}
|
|
10643
11286
|
|
|
10644
11287
|
// src/tools/get-store-info.ts
|
|
@@ -10649,17 +11292,18 @@ var GET_STORE_INFO_SCHEMA = {
|
|
|
10649
11292
|
};
|
|
10650
11293
|
async function handleGetStoreInfo(args) {
|
|
10651
11294
|
try {
|
|
10652
|
-
const
|
|
11295
|
+
const resolved = await resolveStoreInfo(args.connectionId, getCandidateApiUrls());
|
|
10653
11296
|
return {
|
|
10654
11297
|
content: [
|
|
10655
11298
|
{
|
|
10656
11299
|
type: "text",
|
|
10657
11300
|
text: JSON.stringify(
|
|
10658
11301
|
{
|
|
10659
|
-
name: info.name,
|
|
10660
|
-
currency: info.currency,
|
|
10661
|
-
language: info.language,
|
|
10662
|
-
connectionId: args.connectionId
|
|
11302
|
+
name: resolved.info.name,
|
|
11303
|
+
currency: resolved.info.currency,
|
|
11304
|
+
language: resolved.info.language,
|
|
11305
|
+
connectionId: args.connectionId,
|
|
11306
|
+
apiBaseUrl: resolved.apiBaseUrl
|
|
10663
11307
|
},
|
|
10664
11308
|
null,
|
|
10665
11309
|
2
|
|
@@ -10680,41 +11324,65 @@ async function handleGetStoreInfo(args) {
|
|
|
10680
11324
|
import { z as z6 } from "zod";
|
|
10681
11325
|
|
|
10682
11326
|
// src/utils/fetch-store-capabilities.ts
|
|
10683
|
-
|
|
10684
|
-
|
|
11327
|
+
var ConnectionNotFoundError2 = class extends Error {
|
|
11328
|
+
constructor(connectionId, baseUrl) {
|
|
11329
|
+
super(`Connection "${connectionId}" not found at ${baseUrl}`);
|
|
11330
|
+
this.code = "NOT_FOUND";
|
|
11331
|
+
}
|
|
11332
|
+
};
|
|
10685
11333
|
async function fetchStoreCapabilities(connectionId, baseUrl = "https://api.brainerce.com") {
|
|
10686
11334
|
const url = `${baseUrl}/api/vc/${connectionId}/capabilities`;
|
|
10687
|
-
|
|
10688
|
-
|
|
10689
|
-
|
|
10690
|
-
|
|
10691
|
-
|
|
10692
|
-
|
|
10693
|
-
|
|
10694
|
-
|
|
10695
|
-
|
|
10696
|
-
|
|
10697
|
-
|
|
10698
|
-
|
|
10699
|
-
|
|
10700
|
-
|
|
10701
|
-
|
|
10702
|
-
|
|
10703
|
-
|
|
10704
|
-
|
|
10705
|
-
|
|
10706
|
-
|
|
10707
|
-
|
|
10708
|
-
|
|
10709
|
-
});
|
|
10710
|
-
|
|
10711
|
-
|
|
10712
|
-
|
|
10713
|
-
|
|
10714
|
-
|
|
10715
|
-
|
|
10716
|
-
|
|
10717
|
-
|
|
11335
|
+
const controller = new AbortController();
|
|
11336
|
+
const timeout = setTimeout(() => controller.abort(), 1e4);
|
|
11337
|
+
let res;
|
|
11338
|
+
try {
|
|
11339
|
+
res = await fetch(url, { signal: controller.signal });
|
|
11340
|
+
} catch (err) {
|
|
11341
|
+
if (err.name === "AbortError") {
|
|
11342
|
+
throw new Error(`Request to ${baseUrl} timed out`);
|
|
11343
|
+
}
|
|
11344
|
+
throw new Error(`Failed to connect to ${baseUrl}: ${err.message}`);
|
|
11345
|
+
} finally {
|
|
11346
|
+
clearTimeout(timeout);
|
|
11347
|
+
}
|
|
11348
|
+
if (res.status === 404) {
|
|
11349
|
+
throw new ConnectionNotFoundError2(connectionId, baseUrl);
|
|
11350
|
+
}
|
|
11351
|
+
if (!res.ok) {
|
|
11352
|
+
throw new Error(`${baseUrl} returned status ${res.status}`);
|
|
11353
|
+
}
|
|
11354
|
+
try {
|
|
11355
|
+
return await res.json();
|
|
11356
|
+
} catch {
|
|
11357
|
+
throw new Error(`Invalid response from ${baseUrl}`);
|
|
11358
|
+
}
|
|
11359
|
+
}
|
|
11360
|
+
async function resolveStoreCapabilities(connectionId, candidateUrls) {
|
|
11361
|
+
const urls = candidateUrls.filter((u, i) => u && candidateUrls.indexOf(u) === i);
|
|
11362
|
+
if (urls.length === 0) {
|
|
11363
|
+
throw new Error("No API URLs to try");
|
|
11364
|
+
}
|
|
11365
|
+
let lastError;
|
|
11366
|
+
let allNotFound = true;
|
|
11367
|
+
for (let i = 0; i < urls.length; i++) {
|
|
11368
|
+
const baseUrl = urls[i];
|
|
11369
|
+
try {
|
|
11370
|
+
const capabilities = await fetchStoreCapabilities(connectionId, baseUrl);
|
|
11371
|
+
return { capabilities, apiBaseUrl: baseUrl, fellBack: i > 0 };
|
|
11372
|
+
} catch (err) {
|
|
11373
|
+
lastError = err;
|
|
11374
|
+
const isNotFound = err.code === "NOT_FOUND";
|
|
11375
|
+
if (!isNotFound) {
|
|
11376
|
+
allNotFound = false;
|
|
11377
|
+
}
|
|
11378
|
+
}
|
|
11379
|
+
}
|
|
11380
|
+
if (allNotFound) {
|
|
11381
|
+
throw new Error(
|
|
11382
|
+
`Connection "${connectionId}" not found in any known environment (${urls.join(", ")}).`
|
|
11383
|
+
);
|
|
11384
|
+
}
|
|
11385
|
+
throw lastError || new Error("Failed to resolve store capabilities");
|
|
10718
11386
|
}
|
|
10719
11387
|
|
|
10720
11388
|
// src/tools/get-store-capabilities.ts
|
|
@@ -10727,6 +11395,11 @@ function formatCapabilities(caps) {
|
|
|
10727
11395
|
const lines = [];
|
|
10728
11396
|
lines.push(`## Store: ${caps.store.name} (${caps.store.currency})`);
|
|
10729
11397
|
lines.push(`Language: ${caps.store.language}`);
|
|
11398
|
+
if (caps.store.i18n?.enabled) {
|
|
11399
|
+
lines.push(
|
|
11400
|
+
`Multi-language: ENABLED \u2014 locales: ${caps.store.i18n.supportedLocales.join(", ")} (default: ${caps.store.i18n.defaultLocale})`
|
|
11401
|
+
);
|
|
11402
|
+
}
|
|
10730
11403
|
lines.push("");
|
|
10731
11404
|
lines.push("## Configured Features");
|
|
10732
11405
|
if (caps.features.paymentProviders.length > 0) {
|
|
@@ -10813,6 +11486,11 @@ function formatCapabilities(caps) {
|
|
|
10813
11486
|
'Inventory reservation is active \u2014 show a countdown timer. Use get-code-example("reservation-countdown").'
|
|
10814
11487
|
);
|
|
10815
11488
|
}
|
|
11489
|
+
if (caps.store.i18n?.enabled && caps.store.i18n.supportedLocales.length > 1) {
|
|
11490
|
+
suggestions.push(
|
|
11491
|
+
`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.`
|
|
11492
|
+
);
|
|
11493
|
+
}
|
|
10816
11494
|
if (suggestions.length === 0) {
|
|
10817
11495
|
suggestions.push(
|
|
10818
11496
|
"Start by building the required pages. Use get-required-pages() for the checklist."
|
|
@@ -10823,9 +11501,9 @@ function formatCapabilities(caps) {
|
|
|
10823
11501
|
}
|
|
10824
11502
|
async function handleGetStoreCapabilities(args) {
|
|
10825
11503
|
try {
|
|
10826
|
-
const
|
|
11504
|
+
const resolved = await resolveStoreCapabilities(args.connectionId, getCandidateApiUrls());
|
|
10827
11505
|
return {
|
|
10828
|
-
content: [{ type: "text", text: formatCapabilities(
|
|
11506
|
+
content: [{ type: "text", text: formatCapabilities(resolved.capabilities) }]
|
|
10829
11507
|
};
|
|
10830
11508
|
} catch (error) {
|
|
10831
11509
|
const message = error instanceof Error ? error.message : "Failed to fetch store capabilities";
|
|
@@ -10920,6 +11598,20 @@ function formatCapabilitiesSummary(caps) {
|
|
|
10920
11598
|
const lines = [];
|
|
10921
11599
|
lines.push(`## Store: ${caps.store.name}`);
|
|
10922
11600
|
lines.push(`Currency: ${caps.store.currency} | Language: ${caps.store.language}`);
|
|
11601
|
+
if (caps.store.i18n?.enabled && caps.store.i18n.supportedLocales.length > 1) {
|
|
11602
|
+
lines.push(
|
|
11603
|
+
`Multi-language: ${caps.store.i18n.supportedLocales.join(", ")} (default: ${caps.store.i18n.defaultLocale})`
|
|
11604
|
+
);
|
|
11605
|
+
lines.push("");
|
|
11606
|
+
lines.push("### i18n Implementation");
|
|
11607
|
+
lines.push(
|
|
11608
|
+
"- Call `client.setLocale(locale)` at app init based on URL prefix or user preference"
|
|
11609
|
+
);
|
|
11610
|
+
lines.push("- All getProducts/getCategories/getBrands calls auto-include the locale");
|
|
11611
|
+
lines.push("- Use locale-prefixed routes: `/[locale]/products`, `/[locale]/products/[slug]`");
|
|
11612
|
+
lines.push("- Add a language switcher component in the header");
|
|
11613
|
+
lines.push('- RTL locales (he, ar): set `<html dir="rtl">` \u2014 flexbox reversal is automatic');
|
|
11614
|
+
}
|
|
10923
11615
|
lines.push("");
|
|
10924
11616
|
lines.push("### Configured Features");
|
|
10925
11617
|
if (caps.features.paymentProviders.length > 0) {
|
|
@@ -10973,8 +11665,7 @@ function buildStoreBundle(options) {
|
|
|
10973
11665
|
connectionId,
|
|
10974
11666
|
storeName,
|
|
10975
11667
|
currency,
|
|
10976
|
-
language: capabilities?.store.language || "en"
|
|
10977
|
-
apiUrl: "https://api.brainerce.com"
|
|
11668
|
+
language: capabilities?.store.language || "en"
|
|
10978
11669
|
};
|
|
10979
11670
|
const sections = [];
|
|
10980
11671
|
sections.push(`# Build: ${storeName} \u2014 ${storeType}${styleDesc}
|
|
@@ -11077,7 +11768,8 @@ var BUILD_STORE_SCHEMA = {
|
|
|
11077
11768
|
async function handleBuildStore(args) {
|
|
11078
11769
|
let capabilities = null;
|
|
11079
11770
|
try {
|
|
11080
|
-
|
|
11771
|
+
const resolved = await resolveStoreCapabilities(args.connectionId, getCandidateApiUrls());
|
|
11772
|
+
capabilities = resolved.capabilities;
|
|
11081
11773
|
} catch {
|
|
11082
11774
|
}
|
|
11083
11775
|
const bundle = buildStoreBundle({
|
|
@@ -11416,6 +12108,57 @@ ${results.join("\n\n---\n\n")}`
|
|
|
11416
12108
|
};
|
|
11417
12109
|
}
|
|
11418
12110
|
|
|
12111
|
+
// src/tools/get-integration-guide.ts
|
|
12112
|
+
import { z as z10 } from "zod";
|
|
12113
|
+
var GET_INTEGRATION_GUIDE_NAME = "get-integration-guide";
|
|
12114
|
+
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.';
|
|
12115
|
+
var GET_INTEGRATION_GUIDE_SCHEMA = {
|
|
12116
|
+
part: z10.enum(["core", "optional", "rules"]).describe(
|
|
12117
|
+
'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.'
|
|
12118
|
+
)
|
|
12119
|
+
};
|
|
12120
|
+
var GUIDE_URLS = {
|
|
12121
|
+
core: "https://brainerce.com/docs/integration/raw?part=core",
|
|
12122
|
+
optional: "https://brainerce.com/docs/integration/raw?part=optional",
|
|
12123
|
+
rules: "https://brainerce.com/docs/integration/raw?part=rules"
|
|
12124
|
+
};
|
|
12125
|
+
async function handleGetIntegrationGuide(args) {
|
|
12126
|
+
const url = GUIDE_URLS[args.part];
|
|
12127
|
+
if (!url) {
|
|
12128
|
+
return {
|
|
12129
|
+
content: [
|
|
12130
|
+
{
|
|
12131
|
+
type: "text",
|
|
12132
|
+
text: `Unknown part: "${args.part}". Available parts: core, optional, rules.`
|
|
12133
|
+
}
|
|
12134
|
+
]
|
|
12135
|
+
};
|
|
12136
|
+
}
|
|
12137
|
+
try {
|
|
12138
|
+
const response = await fetch(url, {
|
|
12139
|
+
headers: { Accept: "text/plain" },
|
|
12140
|
+
signal: AbortSignal.timeout(15e3)
|
|
12141
|
+
});
|
|
12142
|
+
if (!response.ok) {
|
|
12143
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
12144
|
+
}
|
|
12145
|
+
const content = await response.text();
|
|
12146
|
+
return {
|
|
12147
|
+
content: [{ type: "text", text: content }]
|
|
12148
|
+
};
|
|
12149
|
+
} catch (error) {
|
|
12150
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
12151
|
+
return {
|
|
12152
|
+
content: [
|
|
12153
|
+
{
|
|
12154
|
+
type: "text",
|
|
12155
|
+
text: `Failed to fetch integration guide (${args.part}): ${message}. You can read it directly at: ${url}`
|
|
12156
|
+
}
|
|
12157
|
+
]
|
|
12158
|
+
};
|
|
12159
|
+
}
|
|
12160
|
+
}
|
|
12161
|
+
|
|
11419
12162
|
// src/resources/sdk-types.ts
|
|
11420
12163
|
var SDK_TYPES_URI = "brainerce://sdk/types";
|
|
11421
12164
|
var SDK_TYPES_NAME = "Brainerce SDK Types";
|
|
@@ -11570,15 +12313,15 @@ async function handleProjectTemplate(uri) {
|
|
|
11570
12313
|
}
|
|
11571
12314
|
|
|
11572
12315
|
// src/prompts/create-store.ts
|
|
11573
|
-
import { z as
|
|
12316
|
+
import { z as z11 } from "zod";
|
|
11574
12317
|
var CREATE_STORE_NAME = "create-store";
|
|
11575
12318
|
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
12319
|
var CREATE_STORE_SCHEMA = {
|
|
11577
|
-
connectionId:
|
|
11578
|
-
storeName:
|
|
11579
|
-
storeType:
|
|
11580
|
-
currency:
|
|
11581
|
-
storeStyle:
|
|
12320
|
+
connectionId: z11.string().describe("Vibe-coded connection ID (starts with vc_)"),
|
|
12321
|
+
storeName: z11.string().describe('Name of the store (e.g., "Urban Threads")'),
|
|
12322
|
+
storeType: z11.string().describe('Type of store (e.g., "Clothing store", "Electronics shop", "Bakery")'),
|
|
12323
|
+
currency: z11.string().optional().default("USD").describe("Store currency code (e.g., USD, ILS, EUR)"),
|
|
12324
|
+
storeStyle: z11.string().optional().describe('Design style (e.g., "minimalist", "luxury", "playful", "dark mode")')
|
|
11582
12325
|
};
|
|
11583
12326
|
function handleCreateStore(args) {
|
|
11584
12327
|
const styleDesc = args.storeStyle ? ` with a ${args.storeStyle} design style` : "";
|
|
@@ -11617,6 +12360,13 @@ DO NOT STOP until all 13 pages + header are implemented:
|
|
|
11617
12360
|
13. \`/account\` \u2014 Account dashboard
|
|
11618
12361
|
14. Header component with cart count + search
|
|
11619
12362
|
|
|
12363
|
+
## Multi-Language
|
|
12364
|
+
If the store has multi-language enabled (check build-store output), implement:
|
|
12365
|
+
- Locale-prefixed routes: /[locale]/products instead of /products
|
|
12366
|
+
- Call client.setLocale(locale) based on URL prefix
|
|
12367
|
+
- Language switcher in header
|
|
12368
|
+
- dir="rtl" on <html> for RTL locales (he, ar)
|
|
12369
|
+
|
|
11620
12370
|
## Step 3: Validate
|
|
11621
12371
|
After building all pages, call \`validate-store\` with the list of files you created to verify nothing is missing.
|
|
11622
12372
|
|
|
@@ -11635,11 +12385,11 @@ After building all pages, call \`validate-store\` with the list of files you cre
|
|
|
11635
12385
|
}
|
|
11636
12386
|
|
|
11637
12387
|
// src/prompts/add-feature.ts
|
|
11638
|
-
import { z as
|
|
12388
|
+
import { z as z12 } from "zod";
|
|
11639
12389
|
var ADD_FEATURE_NAME = "add-feature";
|
|
11640
12390
|
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
12391
|
var ADD_FEATURE_SCHEMA = {
|
|
11642
|
-
feature:
|
|
12392
|
+
feature: z12.enum([
|
|
11643
12393
|
"products",
|
|
11644
12394
|
"cart",
|
|
11645
12395
|
"checkout",
|
|
@@ -11652,8 +12402,8 @@ var ADD_FEATURE_SCHEMA = {
|
|
|
11652
12402
|
"search",
|
|
11653
12403
|
"tax"
|
|
11654
12404
|
]).describe("The feature to add"),
|
|
11655
|
-
connectionId:
|
|
11656
|
-
currency:
|
|
12405
|
+
connectionId: z12.string().optional().describe("Vibe-coded connection ID (starts with vc_)"),
|
|
12406
|
+
currency: z12.string().optional().default("USD").describe("Store currency code")
|
|
11657
12407
|
};
|
|
11658
12408
|
var FEATURE_TO_TOPICS = {
|
|
11659
12409
|
products: { topics: ["products"], domains: ["products"] },
|
|
@@ -11768,6 +12518,12 @@ function createServer() {
|
|
|
11768
12518
|
GET_PAGE_CODE_SCHEMA,
|
|
11769
12519
|
handleGetPageCode
|
|
11770
12520
|
);
|
|
12521
|
+
server.tool(
|
|
12522
|
+
GET_INTEGRATION_GUIDE_NAME,
|
|
12523
|
+
GET_INTEGRATION_GUIDE_DESCRIPTION,
|
|
12524
|
+
GET_INTEGRATION_GUIDE_SCHEMA,
|
|
12525
|
+
handleGetIntegrationGuide
|
|
12526
|
+
);
|
|
11771
12527
|
server.resource(
|
|
11772
12528
|
SDK_TYPES_NAME,
|
|
11773
12529
|
SDK_TYPES_URI,
|