@brainerce/mcp-server 1.2.0 → 1.3.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 +404 -30
- package/dist/bin/stdio.js +404 -30
- package/dist/index.js +404 -30
- package/dist/index.mjs +404 -30
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -685,20 +685,25 @@ Display: banners in header, badges on product cards (strikethrough + discounted
|
|
|
685
685
|
function getRecommendationsSection() {
|
|
686
686
|
return `## Product Recommendations (Cross-Sells & Upsells)
|
|
687
687
|
|
|
688
|
+
Recommendations come **embedded in the product response** \u2014 no extra API call needed for product pages.
|
|
689
|
+
|
|
688
690
|
\`\`\`typescript
|
|
689
691
|
import type { ProductRecommendation, ProductRecommendationsResponse, CartRecommendationsResponse } from 'brainerce';
|
|
690
692
|
|
|
691
|
-
// Product page \u2014
|
|
692
|
-
const
|
|
693
|
-
|
|
693
|
+
// Product page \u2014 recommendations are embedded in the product response
|
|
694
|
+
const product = await client.getProductBySlug('some-slug');
|
|
695
|
+
const recs = (product as any).recommendations as ProductRecommendationsResponse | undefined;
|
|
696
|
+
// recs?.upsells \u2014 premium alternatives (show on product page)
|
|
697
|
+
// recs?.related \u2014 similar products (show at bottom of product page)
|
|
698
|
+
// recs?.crossSells \u2014 complementary products (typically used on cart page)
|
|
694
699
|
|
|
695
|
-
// Cart page \u2014 cross-sell suggestions for cart items
|
|
700
|
+
// Cart page \u2014 cross-sell suggestions for cart items (separate call)
|
|
696
701
|
const cartRecs: CartRecommendationsResponse = await client.getCartRecommendations(cartId, 4);
|
|
697
|
-
// cartRecs.recommendations \u2014 deduplicated cross-sells
|
|
702
|
+
// cartRecs.recommendations \u2014 deduplicated cross-sells (excludes items already in cart)
|
|
698
703
|
\`\`\`
|
|
699
704
|
|
|
700
705
|
ProductRecommendation: \`id\`, \`name\`, \`slug\`, \`basePrice\`, \`salePrice\`, \`images\`, \`type\`, \`inventory\`, \`relationType\`.
|
|
701
|
-
Display
|
|
706
|
+
Display upsells + related on product pages, cross-sells on cart page.`;
|
|
702
707
|
}
|
|
703
708
|
function getInventorySection() {
|
|
704
709
|
return `## Inventory, Stock Display & Reservation Countdown
|
|
@@ -2372,7 +2377,7 @@ export default function RootLayout({
|
|
|
2372
2377
|
`,
|
|
2373
2378
|
"src/app/products/page.tsx": `'use client';
|
|
2374
2379
|
|
|
2375
|
-
import { Suspense, useEffect, useState, useCallback } from 'react';
|
|
2380
|
+
import { Suspense, useEffect, useState, useCallback, useRef } from 'react';
|
|
2376
2381
|
import { useSearchParams, useRouter } from 'next/navigation';
|
|
2377
2382
|
import type { Product } from 'brainerce';
|
|
2378
2383
|
import type { ProductQueryParams } from 'brainerce';
|
|
@@ -2398,9 +2403,198 @@ const sortOptions: SortOption[] = [
|
|
|
2398
2403
|
{ labelKey: 'sortPriceHigh', sortBy: 'price', sortOrder: 'desc' },
|
|
2399
2404
|
];
|
|
2400
2405
|
|
|
2401
|
-
interface
|
|
2406
|
+
interface CategoryNode {
|
|
2402
2407
|
id: string;
|
|
2403
2408
|
name: string;
|
|
2409
|
+
parentId?: string | null;
|
|
2410
|
+
children: CategoryNode[];
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
/** Collect all descendant IDs (including self) */
|
|
2414
|
+
function getAllDescendantIds(node: CategoryNode): string[] {
|
|
2415
|
+
const ids = [node.id];
|
|
2416
|
+
for (const child of node.children) {
|
|
2417
|
+
ids.push(...getAllDescendantIds(child));
|
|
2418
|
+
}
|
|
2419
|
+
return ids;
|
|
2420
|
+
}
|
|
2421
|
+
|
|
2422
|
+
/** Check if a category or any of its descendants matches the selected ID */
|
|
2423
|
+
function isActiveInTree(node: CategoryNode, selectedId: string): boolean {
|
|
2424
|
+
if (node.id === selectedId) return true;
|
|
2425
|
+
return node.children.some((child) => isActiveInTree(child, selectedId));
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
/** Chevron down SVG */
|
|
2429
|
+
function ChevronDown({ className }: { className?: string }) {
|
|
2430
|
+
return (
|
|
2431
|
+
<svg
|
|
2432
|
+
className={className}
|
|
2433
|
+
width="12"
|
|
2434
|
+
height="12"
|
|
2435
|
+
viewBox="0 0 12 12"
|
|
2436
|
+
fill="none"
|
|
2437
|
+
stroke="currentColor"
|
|
2438
|
+
strokeWidth="2"
|
|
2439
|
+
strokeLinecap="round"
|
|
2440
|
+
strokeLinejoin="round"
|
|
2441
|
+
>
|
|
2442
|
+
<path d="M3 4.5L6 7.5L9 4.5" />
|
|
2443
|
+
</svg>
|
|
2444
|
+
);
|
|
2445
|
+
}
|
|
2446
|
+
|
|
2447
|
+
/** Recursive dropdown items for nested categories */
|
|
2448
|
+
function CategoryDropdownItems({
|
|
2449
|
+
children,
|
|
2450
|
+
depth,
|
|
2451
|
+
selectedId,
|
|
2452
|
+
onSelect,
|
|
2453
|
+
}: {
|
|
2454
|
+
children: CategoryNode[];
|
|
2455
|
+
depth: number;
|
|
2456
|
+
selectedId: string;
|
|
2457
|
+
onSelect: (id: string) => void;
|
|
2458
|
+
}) {
|
|
2459
|
+
return (
|
|
2460
|
+
<>
|
|
2461
|
+
{children.map((child) => (
|
|
2462
|
+
<div key={child.id}>
|
|
2463
|
+
<button
|
|
2464
|
+
onClick={() => onSelect(child.id)}
|
|
2465
|
+
className={cn(
|
|
2466
|
+
'w-full text-start px-4 py-2 text-sm transition-colors hover:bg-muted',
|
|
2467
|
+
selectedId === child.id && 'bg-primary/10 text-primary font-medium'
|
|
2468
|
+
)}
|
|
2469
|
+
style={{ paddingInlineStart: \`\${(depth + 1) * 16}px\` }}
|
|
2470
|
+
>
|
|
2471
|
+
{child.name}
|
|
2472
|
+
</button>
|
|
2473
|
+
{child.children.length > 0 && (
|
|
2474
|
+
<CategoryDropdownItems
|
|
2475
|
+
children={child.children}
|
|
2476
|
+
depth={depth + 1}
|
|
2477
|
+
selectedId={selectedId}
|
|
2478
|
+
onSelect={onSelect}
|
|
2479
|
+
/>
|
|
2480
|
+
)}
|
|
2481
|
+
</div>
|
|
2482
|
+
))}
|
|
2483
|
+
</>
|
|
2484
|
+
);
|
|
2485
|
+
}
|
|
2486
|
+
|
|
2487
|
+
/** Category chip with dropdown for subcategories */
|
|
2488
|
+
function CategoryChip({
|
|
2489
|
+
category,
|
|
2490
|
+
selectedId,
|
|
2491
|
+
onSelect,
|
|
2492
|
+
tc,
|
|
2493
|
+
}: {
|
|
2494
|
+
category: CategoryNode;
|
|
2495
|
+
selectedId: string;
|
|
2496
|
+
onSelect: (id: string) => void;
|
|
2497
|
+
tc: (key: string) => string;
|
|
2498
|
+
}) {
|
|
2499
|
+
const [open, setOpen] = useState(false);
|
|
2500
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
2501
|
+
const hasChildren = category.children.length > 0;
|
|
2502
|
+
const isActive = isActiveInTree(category, selectedId);
|
|
2503
|
+
|
|
2504
|
+
// Find the display name for the selected subcategory
|
|
2505
|
+
function findName(nodes: CategoryNode[], id: string): string | null {
|
|
2506
|
+
for (const n of nodes) {
|
|
2507
|
+
if (n.id === id) return n.name;
|
|
2508
|
+
const found = findName(n.children, id);
|
|
2509
|
+
if (found) return found;
|
|
2510
|
+
}
|
|
2511
|
+
return null;
|
|
2512
|
+
}
|
|
2513
|
+
|
|
2514
|
+
const selectedChildName =
|
|
2515
|
+
isActive && selectedId !== category.id ? findName(category.children, selectedId) : null;
|
|
2516
|
+
|
|
2517
|
+
// Close dropdown on outside click
|
|
2518
|
+
useEffect(() => {
|
|
2519
|
+
if (!open) return;
|
|
2520
|
+
function handleClick(e: MouseEvent) {
|
|
2521
|
+
if (ref.current && !ref.current.contains(e.target as Node)) {
|
|
2522
|
+
setOpen(false);
|
|
2523
|
+
}
|
|
2524
|
+
}
|
|
2525
|
+
document.addEventListener('mousedown', handleClick);
|
|
2526
|
+
return () => document.removeEventListener('mousedown', handleClick);
|
|
2527
|
+
}, [open]);
|
|
2528
|
+
|
|
2529
|
+
if (!hasChildren) {
|
|
2530
|
+
return (
|
|
2531
|
+
<button
|
|
2532
|
+
onClick={() => onSelect(category.id)}
|
|
2533
|
+
className={cn(
|
|
2534
|
+
'rounded-full border px-3 py-1.5 text-sm transition-colors',
|
|
2535
|
+
selectedId === category.id
|
|
2536
|
+
? 'bg-primary text-primary-foreground border-primary'
|
|
2537
|
+
: 'border-border text-muted-foreground hover:border-primary hover:text-foreground'
|
|
2538
|
+
)}
|
|
2539
|
+
>
|
|
2540
|
+
{category.name}
|
|
2541
|
+
</button>
|
|
2542
|
+
);
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2545
|
+
return (
|
|
2546
|
+
<div ref={ref} className="relative">
|
|
2547
|
+
<button
|
|
2548
|
+
onClick={() => setOpen((prev) => !prev)}
|
|
2549
|
+
className={cn(
|
|
2550
|
+
'inline-flex items-center gap-1 rounded-full border px-3 py-1.5 text-sm transition-colors',
|
|
2551
|
+
isActive
|
|
2552
|
+
? 'bg-primary text-primary-foreground border-primary'
|
|
2553
|
+
: 'border-border text-muted-foreground hover:border-primary hover:text-foreground'
|
|
2554
|
+
)}
|
|
2555
|
+
>
|
|
2556
|
+
{category.name}
|
|
2557
|
+
{selectedChildName && (
|
|
2558
|
+
<span className="opacity-80">
|
|
2559
|
+
{'\xB7'} {selectedChildName}
|
|
2560
|
+
</span>
|
|
2561
|
+
)}
|
|
2562
|
+
<ChevronDown
|
|
2563
|
+
className={cn('transition-transform ms-0.5', open && 'rotate-180')}
|
|
2564
|
+
/>
|
|
2565
|
+
</button>
|
|
2566
|
+
|
|
2567
|
+
{open && (
|
|
2568
|
+
<div className="bg-background border-border absolute start-0 top-full z-50 mt-1 min-w-[180px] overflow-hidden rounded-lg border shadow-lg">
|
|
2569
|
+
{/* "All in [category]" option */}
|
|
2570
|
+
<button
|
|
2571
|
+
onClick={() => {
|
|
2572
|
+
onSelect(category.id);
|
|
2573
|
+
setOpen(false);
|
|
2574
|
+
}}
|
|
2575
|
+
className={cn(
|
|
2576
|
+
'w-full text-start px-4 py-2 text-sm font-medium transition-colors hover:bg-muted',
|
|
2577
|
+
selectedId === category.id && 'bg-primary/10 text-primary'
|
|
2578
|
+
)}
|
|
2579
|
+
>
|
|
2580
|
+
{tc('all')} {category.name}
|
|
2581
|
+
</button>
|
|
2582
|
+
<div className="bg-border mx-2 h-px" />
|
|
2583
|
+
{/* Recursive children */}
|
|
2584
|
+
<div
|
|
2585
|
+
onClick={() => setOpen(false)}
|
|
2586
|
+
>
|
|
2587
|
+
<CategoryDropdownItems
|
|
2588
|
+
children={category.children}
|
|
2589
|
+
depth={0}
|
|
2590
|
+
selectedId={selectedId}
|
|
2591
|
+
onSelect={onSelect}
|
|
2592
|
+
/>
|
|
2593
|
+
</div>
|
|
2594
|
+
</div>
|
|
2595
|
+
)}
|
|
2596
|
+
</div>
|
|
2597
|
+
);
|
|
2404
2598
|
}
|
|
2405
2599
|
|
|
2406
2600
|
function ProductsContent() {
|
|
@@ -2419,18 +2613,18 @@ function ProductsContent() {
|
|
|
2419
2613
|
const [page, setPage] = useState(1);
|
|
2420
2614
|
const [totalPages, setTotalPages] = useState(1);
|
|
2421
2615
|
const [total, setTotal] = useState(0);
|
|
2422
|
-
const [categories, setCategories] = useState<
|
|
2616
|
+
const [categories, setCategories] = useState<CategoryNode[]>([]);
|
|
2423
2617
|
|
|
2424
2618
|
const sortIndex = parseInt(sortParam, 10) || 0;
|
|
2425
2619
|
const currentSort = sortOptions[sortIndex] || sortOptions[0];
|
|
2426
2620
|
|
|
2427
|
-
// Load categories
|
|
2621
|
+
// Load categories (keep tree structure)
|
|
2428
2622
|
useEffect(() => {
|
|
2429
2623
|
async function loadCategories() {
|
|
2430
2624
|
try {
|
|
2431
2625
|
const client = getClient();
|
|
2432
2626
|
const result = await client.getCategories();
|
|
2433
|
-
setCategories(result.categories
|
|
2627
|
+
setCategories(result.categories as CategoryNode[]);
|
|
2434
2628
|
} catch {
|
|
2435
2629
|
// Categories endpoint may not be available in all modes
|
|
2436
2630
|
}
|
|
@@ -2499,6 +2693,10 @@ function ProductsContent() {
|
|
|
2499
2693
|
router.push(\`/products?\${params.toString()}\`);
|
|
2500
2694
|
}
|
|
2501
2695
|
|
|
2696
|
+
function handleCategorySelect(id: string) {
|
|
2697
|
+
updateParam('category', id);
|
|
2698
|
+
}
|
|
2699
|
+
|
|
2502
2700
|
return (
|
|
2503
2701
|
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
|
2504
2702
|
{/* Page Header */}
|
|
@@ -2530,18 +2728,13 @@ function ProductsContent() {
|
|
|
2530
2728
|
{tc('all')}
|
|
2531
2729
|
</button>
|
|
2532
2730
|
{categories.map((cat) => (
|
|
2533
|
-
<
|
|
2731
|
+
<CategoryChip
|
|
2534
2732
|
key={cat.id}
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
: 'border-border text-muted-foreground hover:border-primary hover:text-foreground'
|
|
2541
|
-
)}
|
|
2542
|
-
>
|
|
2543
|
-
{cat.name}
|
|
2544
|
-
</button>
|
|
2733
|
+
category={cat}
|
|
2734
|
+
selectedId={categoryId}
|
|
2735
|
+
onSelect={handleCategorySelect}
|
|
2736
|
+
tc={tc as (key: string) => string}
|
|
2737
|
+
/>
|
|
2545
2738
|
))}
|
|
2546
2739
|
</div>
|
|
2547
2740
|
)}
|
|
@@ -2623,7 +2816,18 @@ import { useEffect, useState, useMemo } from 'react';
|
|
|
2623
2816
|
import { useParams } from 'next/navigation';
|
|
2624
2817
|
import Image from 'next/image';
|
|
2625
2818
|
import Link from 'next/link';
|
|
2626
|
-
import type {
|
|
2819
|
+
import type {
|
|
2820
|
+
Product,
|
|
2821
|
+
ProductVariant,
|
|
2822
|
+
ProductImage,
|
|
2823
|
+
ProductMetafield,
|
|
2824
|
+
ProductRecommendationsResponse,
|
|
2825
|
+
DownloadFile,
|
|
2826
|
+
} from 'brainerce';
|
|
2827
|
+
|
|
2828
|
+
type ProductWithRecommendations = Product & {
|
|
2829
|
+
recommendations?: ProductRecommendationsResponse;
|
|
2830
|
+
};
|
|
2627
2831
|
import { getProductPriceInfo, getDescriptionContent } from 'brainerce';
|
|
2628
2832
|
import { getClient } from '@/lib/brainerce';
|
|
2629
2833
|
import { useCart } from '@/providers/store-provider';
|
|
@@ -2631,6 +2835,7 @@ import { PriceDisplay } from '@/components/shared/price-display';
|
|
|
2631
2835
|
import { LoadingSpinner } from '@/components/shared/loading-spinner';
|
|
2632
2836
|
import { VariantSelector } from '@/components/products/variant-selector';
|
|
2633
2837
|
import { StockBadge } from '@/components/products/stock-badge';
|
|
2838
|
+
import { RecommendationSection } from '@/components/products/recommendation-section';
|
|
2634
2839
|
import { useTranslations } from '@/lib/translations';
|
|
2635
2840
|
import { cn } from '@/lib/utils';
|
|
2636
2841
|
|
|
@@ -2724,7 +2929,7 @@ export default function ProductDetailPage() {
|
|
|
2724
2929
|
const { refreshCart } = useCart();
|
|
2725
2930
|
const t = useTranslations('productDetail');
|
|
2726
2931
|
const tc = useTranslations('common');
|
|
2727
|
-
const [product, setProduct] = useState<
|
|
2932
|
+
const [product, setProduct] = useState<ProductWithRecommendations | null>(null);
|
|
2728
2933
|
const [loading, setLoading] = useState(true);
|
|
2729
2934
|
const [error, setError] = useState<string | null>(null);
|
|
2730
2935
|
const [selectedVariant, setSelectedVariant] = useState<ProductVariant | null>(null);
|
|
@@ -2733,6 +2938,8 @@ export default function ProductDetailPage() {
|
|
|
2733
2938
|
const [addingToCart, setAddingToCart] = useState(false);
|
|
2734
2939
|
const [addedMessage, setAddedMessage] = useState(false);
|
|
2735
2940
|
|
|
2941
|
+
const recommendations = product?.recommendations ?? null;
|
|
2942
|
+
|
|
2736
2943
|
// Load product
|
|
2737
2944
|
useEffect(() => {
|
|
2738
2945
|
async function load() {
|
|
@@ -2741,7 +2948,7 @@ export default function ProductDetailPage() {
|
|
|
2741
2948
|
setError(null);
|
|
2742
2949
|
const client = getClient();
|
|
2743
2950
|
const p = await client.getProductBySlug(slug);
|
|
2744
|
-
setProduct(p);
|
|
2951
|
+
setProduct(p as ProductWithRecommendations);
|
|
2745
2952
|
|
|
2746
2953
|
// Auto-select first variant
|
|
2747
2954
|
if (p.variants && p.variants.length > 0) {
|
|
@@ -2947,8 +3154,43 @@ export default function ProductDetailPage() {
|
|
|
2947
3154
|
size="lg"
|
|
2948
3155
|
/>
|
|
2949
3156
|
|
|
2950
|
-
{/* Stock */}
|
|
2951
|
-
|
|
3157
|
+
{/* Stock / Digital badge */}
|
|
3158
|
+
{product.isDownloadable ? (
|
|
3159
|
+
<span className="inline-flex items-center gap-1.5 rounded-full bg-green-100 px-3 py-1 text-xs font-medium text-green-800 dark:bg-green-950/30 dark:text-green-400">
|
|
3160
|
+
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
3161
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
|
3162
|
+
</svg>
|
|
3163
|
+
{t('instantDownload')}
|
|
3164
|
+
</span>
|
|
3165
|
+
) : (
|
|
3166
|
+
<StockBadge inventory={inventory} lowStockThreshold={5} />
|
|
3167
|
+
)}
|
|
3168
|
+
|
|
3169
|
+
{/* Downloadable files info */}
|
|
3170
|
+
{product.isDownloadable && product.downloads && product.downloads.length > 0 && (
|
|
3171
|
+
<div className="bg-muted/50 rounded-lg border p-4">
|
|
3172
|
+
<p className="text-foreground mb-2 text-sm font-medium">
|
|
3173
|
+
{t('filesIncluded', { count: product.downloads.length })}
|
|
3174
|
+
</p>
|
|
3175
|
+
<ul className="space-y-1.5">
|
|
3176
|
+
{product.downloads.map((file: DownloadFile) => (
|
|
3177
|
+
<li key={file.id} className="text-muted-foreground flex items-center gap-2 text-sm">
|
|
3178
|
+
<svg className="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
3179
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
|
3180
|
+
</svg>
|
|
3181
|
+
<span className="truncate">{file.name}</span>
|
|
3182
|
+
{file.size && (
|
|
3183
|
+
<span className="flex-shrink-0 text-xs">
|
|
3184
|
+
({file.size < 1024 * 1024
|
|
3185
|
+
? \`\${(file.size / 1024).toFixed(0)} KB\`
|
|
3186
|
+
: \`\${(file.size / (1024 * 1024)).toFixed(1)} MB\`})
|
|
3187
|
+
</span>
|
|
3188
|
+
)}
|
|
3189
|
+
</li>
|
|
3190
|
+
))}
|
|
3191
|
+
</ul>
|
|
3192
|
+
</div>
|
|
3193
|
+
)}
|
|
2952
3194
|
|
|
2953
3195
|
{/* Variant Selector */}
|
|
2954
3196
|
{product.type === 'VARIABLE' && product.variants && product.variants.length > 0 && (
|
|
@@ -3012,6 +3254,13 @@ export default function ProductDetailPage() {
|
|
|
3012
3254
|
</button>
|
|
3013
3255
|
</div>
|
|
3014
3256
|
|
|
3257
|
+
{/* Download after purchase note */}
|
|
3258
|
+
{product.isDownloadable && (
|
|
3259
|
+
<p className="text-muted-foreground text-sm">
|
|
3260
|
+
{t('downloadAfterPurchase')}
|
|
3261
|
+
</p>
|
|
3262
|
+
)}
|
|
3263
|
+
|
|
3015
3264
|
{/* Description */}
|
|
3016
3265
|
{description && (
|
|
3017
3266
|
<div className="border-border border-t pt-4">
|
|
@@ -3049,19 +3298,41 @@ export default function ProductDetailPage() {
|
|
|
3049
3298
|
)}
|
|
3050
3299
|
</div>
|
|
3051
3300
|
</div>
|
|
3301
|
+
|
|
3302
|
+
{/* Upsells \u2014 premium alternatives (product page) */}
|
|
3303
|
+
{recommendations?.upsells && recommendations.upsells.length > 0 && (
|
|
3304
|
+
<RecommendationSection
|
|
3305
|
+
title={t('upgradeYourChoice')}
|
|
3306
|
+
items={recommendations.upsells}
|
|
3307
|
+
className="mt-12"
|
|
3308
|
+
/>
|
|
3309
|
+
)}
|
|
3310
|
+
|
|
3311
|
+
{/* Related products \u2014 similar items (bottom of product page) */}
|
|
3312
|
+
{recommendations?.related && recommendations.related.length > 0 && (
|
|
3313
|
+
<RecommendationSection
|
|
3314
|
+
title={t('similarProducts')}
|
|
3315
|
+
items={recommendations.related}
|
|
3316
|
+
className="mt-12"
|
|
3317
|
+
/>
|
|
3318
|
+
)}
|
|
3052
3319
|
</div>
|
|
3053
3320
|
);
|
|
3054
3321
|
}
|
|
3055
3322
|
`,
|
|
3056
3323
|
"src/app/cart/page.tsx": `'use client';
|
|
3057
3324
|
|
|
3325
|
+
import { useEffect, useState } from 'react';
|
|
3058
3326
|
import Link from 'next/link';
|
|
3327
|
+
import type { CartRecommendationsResponse } from 'brainerce';
|
|
3328
|
+
import { getClient } from '@/lib/brainerce';
|
|
3059
3329
|
import { useCart } from '@/providers/store-provider';
|
|
3060
3330
|
import { CartItem } from '@/components/cart/cart-item';
|
|
3061
3331
|
import { CartSummary } from '@/components/cart/cart-summary';
|
|
3062
3332
|
import { CouponInput } from '@/components/cart/coupon-input';
|
|
3063
3333
|
import { CartNudges } from '@/components/cart/cart-nudges';
|
|
3064
3334
|
import { ReservationCountdown } from '@/components/cart/reservation-countdown';
|
|
3335
|
+
import { CartRecommendationSection } from '@/components/products/recommendation-section';
|
|
3065
3336
|
import { LoadingSpinner } from '@/components/shared/loading-spinner';
|
|
3066
3337
|
import { useTranslations } from '@/lib/translations';
|
|
3067
3338
|
|
|
@@ -3069,6 +3340,17 @@ export default function CartPage() {
|
|
|
3069
3340
|
const { cart, cartLoading, refreshCart, itemCount } = useCart();
|
|
3070
3341
|
const t = useTranslations('cart');
|
|
3071
3342
|
const tc = useTranslations('common');
|
|
3343
|
+
const [cartRecs, setCartRecs] = useState<CartRecommendationsResponse | null>(null);
|
|
3344
|
+
|
|
3345
|
+
// Load cross-sell recommendations when cart changes
|
|
3346
|
+
useEffect(() => {
|
|
3347
|
+
if (!cart?.id || cart.items.length === 0) {
|
|
3348
|
+
setCartRecs(null);
|
|
3349
|
+
return;
|
|
3350
|
+
}
|
|
3351
|
+
const client = getClient();
|
|
3352
|
+
client.getCartRecommendations(cart.id, 4).then(setCartRecs).catch(() => {});
|
|
3353
|
+
}, [cart?.id, cart?.items.length]);
|
|
3072
3354
|
|
|
3073
3355
|
if (cartLoading) {
|
|
3074
3356
|
return (
|
|
@@ -3160,6 +3442,15 @@ export default function CartPage() {
|
|
|
3160
3442
|
</div>
|
|
3161
3443
|
</div>
|
|
3162
3444
|
</div>
|
|
3445
|
+
|
|
3446
|
+
{/* Cross-sell recommendations */}
|
|
3447
|
+
{cartRecs?.recommendations && cartRecs.recommendations.length > 0 && (
|
|
3448
|
+
<CartRecommendationSection
|
|
3449
|
+
title={t('youMightAlsoNeed')}
|
|
3450
|
+
items={cartRecs.recommendations}
|
|
3451
|
+
className="mt-10"
|
|
3452
|
+
/>
|
|
3453
|
+
)}
|
|
3163
3454
|
</div>
|
|
3164
3455
|
);
|
|
3165
3456
|
}
|
|
@@ -4704,6 +4995,12 @@ async function proxyRequest(
|
|
|
4704
4995
|
'Content-Type': 'application/json',
|
|
4705
4996
|
};
|
|
4706
4997
|
|
|
4998
|
+
// Forward Origin/Referer so backend BrowserOriginGuard accepts proxied requests
|
|
4999
|
+
const origin = request.headers.get('origin');
|
|
5000
|
+
const referer = request.headers.get('referer');
|
|
5001
|
+
if (origin) headers['Origin'] = origin;
|
|
5002
|
+
if (referer) headers['Referer'] = referer;
|
|
5003
|
+
|
|
4707
5004
|
// Forward SDK version header if present
|
|
4708
5005
|
const sdkVersion = request.headers.get('x-sdk-version');
|
|
4709
5006
|
if (sdkVersion) {
|
|
@@ -4989,6 +5286,7 @@ interface ProductCardProps {
|
|
|
4989
5286
|
|
|
4990
5287
|
export function ProductCard({ product, className }: ProductCardProps) {
|
|
4991
5288
|
const t = useTranslations('common');
|
|
5289
|
+
const tp = useTranslations('productDetail');
|
|
4992
5290
|
const { price, originalPrice, isOnSale } = getProductPriceInfo(product);
|
|
4993
5291
|
const mainImage = product.images?.[0];
|
|
4994
5292
|
const imageUrl = mainImage?.url || null;
|
|
@@ -5033,6 +5331,11 @@ export function ProductCard({ product, className }: ProductCardProps) {
|
|
|
5033
5331
|
</span>
|
|
5034
5332
|
)}
|
|
5035
5333
|
<DiscountBadge discount={product.discount} />
|
|
5334
|
+
{product.isDownloadable && (
|
|
5335
|
+
<span className="bg-primary text-primary-foreground rounded px-2 py-1 text-xs font-bold">
|
|
5336
|
+
{tp('digitalProduct')}
|
|
5337
|
+
</span>
|
|
5338
|
+
)}
|
|
5036
5339
|
</div>
|
|
5037
5340
|
</div>
|
|
5038
5341
|
|
|
@@ -7368,10 +7671,11 @@ export function ProfileSection({ profile, onProfileUpdate, className }: ProfileS
|
|
|
7368
7671
|
`,
|
|
7369
7672
|
"src/components/account/order-history.tsx": `'use client';
|
|
7370
7673
|
|
|
7371
|
-
import { useState } from 'react';
|
|
7674
|
+
import { useState, useEffect } from 'react';
|
|
7372
7675
|
import Image from 'next/image';
|
|
7373
|
-
import type { Order, OrderStatus } from 'brainerce';
|
|
7676
|
+
import type { Order, OrderStatus, OrderDownloadLink } from 'brainerce';
|
|
7374
7677
|
import { formatPrice } from 'brainerce';
|
|
7678
|
+
import { getClient } from '@/lib/brainerce';
|
|
7375
7679
|
import { useTranslations } from '@/lib/translations';
|
|
7376
7680
|
import { cn } from '@/lib/utils';
|
|
7377
7681
|
|
|
@@ -7555,6 +7859,11 @@ function OrderCard({ order }: { order: Order }) {
|
|
|
7555
7859
|
</div>
|
|
7556
7860
|
))}
|
|
7557
7861
|
|
|
7862
|
+
{/* Downloads section */}
|
|
7863
|
+
{order.hasDownloads && (
|
|
7864
|
+
<OrderDownloads orderId={order.id} />
|
|
7865
|
+
)}
|
|
7866
|
+
|
|
7558
7867
|
<OrderFinancialSummary order={order} currency={currency} />
|
|
7559
7868
|
</div>
|
|
7560
7869
|
)}
|
|
@@ -7562,6 +7871,71 @@ function OrderCard({ order }: { order: Order }) {
|
|
|
7562
7871
|
);
|
|
7563
7872
|
}
|
|
7564
7873
|
|
|
7874
|
+
function OrderDownloads({ orderId }: { orderId: string }) {
|
|
7875
|
+
const t = useTranslations('account');
|
|
7876
|
+
const [downloads, setDownloads] = useState<OrderDownloadLink[] | null>(null);
|
|
7877
|
+
const [loading, setLoading] = useState(true);
|
|
7878
|
+
|
|
7879
|
+
useEffect(() => {
|
|
7880
|
+
let cancelled = false;
|
|
7881
|
+
async function fetch() {
|
|
7882
|
+
try {
|
|
7883
|
+
const client = getClient();
|
|
7884
|
+
const links = await client.getOrderDownloads(orderId);
|
|
7885
|
+
if (!cancelled) setDownloads(links);
|
|
7886
|
+
} catch {
|
|
7887
|
+
if (!cancelled) setDownloads([]);
|
|
7888
|
+
} finally {
|
|
7889
|
+
if (!cancelled) setLoading(false);
|
|
7890
|
+
}
|
|
7891
|
+
}
|
|
7892
|
+
fetch();
|
|
7893
|
+
return () => { cancelled = true; };
|
|
7894
|
+
}, [orderId]);
|
|
7895
|
+
|
|
7896
|
+
if (loading) {
|
|
7897
|
+
return (
|
|
7898
|
+
<div className="border-border border-t pt-2">
|
|
7899
|
+
<p className="text-muted-foreground animate-pulse text-xs">{t('downloads')}...</p>
|
|
7900
|
+
</div>
|
|
7901
|
+
);
|
|
7902
|
+
}
|
|
7903
|
+
|
|
7904
|
+
if (!downloads || downloads.length === 0) return null;
|
|
7905
|
+
|
|
7906
|
+
return (
|
|
7907
|
+
<div className="border-border space-y-2 border-t pt-2">
|
|
7908
|
+
<p className="text-foreground text-sm font-medium">{t('downloads')}</p>
|
|
7909
|
+
{downloads.map((link, idx) => (
|
|
7910
|
+
<div key={idx} className="flex items-center gap-3">
|
|
7911
|
+
<div className="min-w-0 flex-1">
|
|
7912
|
+
<p className="text-foreground truncate text-sm">{link.fileName}</p>
|
|
7913
|
+
<p className="text-muted-foreground text-xs">
|
|
7914
|
+
{link.productName}
|
|
7915
|
+
{' \xB7 '}
|
|
7916
|
+
{link.downloadLimit != null
|
|
7917
|
+
? t('downloadsRemaining', { used: link.downloadsUsed, limit: link.downloadLimit })
|
|
7918
|
+
: t('unlimitedDownloads')}
|
|
7919
|
+
{' \xB7 '}
|
|
7920
|
+
{link.expiresAt
|
|
7921
|
+
? t('expiresAt', { date: new Date(link.expiresAt).toLocaleDateString() })
|
|
7922
|
+
: t('noExpiry')}
|
|
7923
|
+
</p>
|
|
7924
|
+
</div>
|
|
7925
|
+
<a
|
|
7926
|
+
href={link.downloadUrl}
|
|
7927
|
+
target="_blank"
|
|
7928
|
+
rel="noopener noreferrer"
|
|
7929
|
+
className="bg-primary text-primary-foreground flex-shrink-0 rounded px-3 py-1 text-xs font-medium hover:opacity-90"
|
|
7930
|
+
>
|
|
7931
|
+
{t('downloadFile')}
|
|
7932
|
+
</a>
|
|
7933
|
+
</div>
|
|
7934
|
+
))}
|
|
7935
|
+
</div>
|
|
7936
|
+
);
|
|
7937
|
+
}
|
|
7938
|
+
|
|
7565
7939
|
function OrderFinancialSummary({ order, currency }: { order: Order; currency: string }) {
|
|
7566
7940
|
const tc = useTranslations('common');
|
|
7567
7941
|
const totalAmount = order.totalAmount || order.total || '0';
|
|
@@ -8443,7 +8817,7 @@ var DETAILED = `# Required Pages \u2014 Detailed Guide
|
|
|
8443
8817
|
- Stock badge
|
|
8444
8818
|
- Discount badge (\`getProductDiscountBadge()\`)
|
|
8445
8819
|
- Add to cart button (disabled when \`!canPurchase\`)
|
|
8446
|
-
- Product recommendations (\`
|
|
8820
|
+
- Product recommendations (from \`product.recommendations\` \u2014 embedded in product response)
|
|
8447
8821
|
- Use \`client.getProductBySlug(slug)\`
|
|
8448
8822
|
|
|
8449
8823
|
## 4. Cart (\`/cart\`)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brainerce/mcp-server",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "MCP server for AI-powered Brainerce store building. Provides SDK docs, types, and code examples to AI tools like Lovable, Cursor, and Claude Code.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"brainerce-mcp": "dist/bin/stdio.js"
|