@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.js
CHANGED
|
@@ -728,20 +728,25 @@ Display: banners in header, badges on product cards (strikethrough + discounted
|
|
|
728
728
|
function getRecommendationsSection() {
|
|
729
729
|
return `## Product Recommendations (Cross-Sells & Upsells)
|
|
730
730
|
|
|
731
|
+
Recommendations come **embedded in the product response** \u2014 no extra API call needed for product pages.
|
|
732
|
+
|
|
731
733
|
\`\`\`typescript
|
|
732
734
|
import type { ProductRecommendation, ProductRecommendationsResponse, CartRecommendationsResponse } from 'brainerce';
|
|
733
735
|
|
|
734
|
-
// Product page \u2014
|
|
735
|
-
const
|
|
736
|
-
|
|
736
|
+
// Product page \u2014 recommendations are embedded in the product response
|
|
737
|
+
const product = await client.getProductBySlug('some-slug');
|
|
738
|
+
const recs = (product as any).recommendations as ProductRecommendationsResponse | undefined;
|
|
739
|
+
// recs?.upsells \u2014 premium alternatives (show on product page)
|
|
740
|
+
// recs?.related \u2014 similar products (show at bottom of product page)
|
|
741
|
+
// recs?.crossSells \u2014 complementary products (typically used on cart page)
|
|
737
742
|
|
|
738
|
-
// Cart page \u2014 cross-sell suggestions for cart items
|
|
743
|
+
// Cart page \u2014 cross-sell suggestions for cart items (separate call)
|
|
739
744
|
const cartRecs: CartRecommendationsResponse = await client.getCartRecommendations(cartId, 4);
|
|
740
|
-
// cartRecs.recommendations \u2014 deduplicated cross-sells
|
|
745
|
+
// cartRecs.recommendations \u2014 deduplicated cross-sells (excludes items already in cart)
|
|
741
746
|
\`\`\`
|
|
742
747
|
|
|
743
748
|
ProductRecommendation: \`id\`, \`name\`, \`slug\`, \`basePrice\`, \`salePrice\`, \`images\`, \`type\`, \`inventory\`, \`relationType\`.
|
|
744
|
-
Display
|
|
749
|
+
Display upsells + related on product pages, cross-sells on cart page.`;
|
|
745
750
|
}
|
|
746
751
|
function getInventorySection() {
|
|
747
752
|
return `## Inventory, Stock Display & Reservation Countdown
|
|
@@ -2415,7 +2420,7 @@ export default function RootLayout({
|
|
|
2415
2420
|
`,
|
|
2416
2421
|
"src/app/products/page.tsx": `'use client';
|
|
2417
2422
|
|
|
2418
|
-
import { Suspense, useEffect, useState, useCallback } from 'react';
|
|
2423
|
+
import { Suspense, useEffect, useState, useCallback, useRef } from 'react';
|
|
2419
2424
|
import { useSearchParams, useRouter } from 'next/navigation';
|
|
2420
2425
|
import type { Product } from 'brainerce';
|
|
2421
2426
|
import type { ProductQueryParams } from 'brainerce';
|
|
@@ -2441,9 +2446,198 @@ const sortOptions: SortOption[] = [
|
|
|
2441
2446
|
{ labelKey: 'sortPriceHigh', sortBy: 'price', sortOrder: 'desc' },
|
|
2442
2447
|
];
|
|
2443
2448
|
|
|
2444
|
-
interface
|
|
2449
|
+
interface CategoryNode {
|
|
2445
2450
|
id: string;
|
|
2446
2451
|
name: string;
|
|
2452
|
+
parentId?: string | null;
|
|
2453
|
+
children: CategoryNode[];
|
|
2454
|
+
}
|
|
2455
|
+
|
|
2456
|
+
/** Collect all descendant IDs (including self) */
|
|
2457
|
+
function getAllDescendantIds(node: CategoryNode): string[] {
|
|
2458
|
+
const ids = [node.id];
|
|
2459
|
+
for (const child of node.children) {
|
|
2460
|
+
ids.push(...getAllDescendantIds(child));
|
|
2461
|
+
}
|
|
2462
|
+
return ids;
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2465
|
+
/** Check if a category or any of its descendants matches the selected ID */
|
|
2466
|
+
function isActiveInTree(node: CategoryNode, selectedId: string): boolean {
|
|
2467
|
+
if (node.id === selectedId) return true;
|
|
2468
|
+
return node.children.some((child) => isActiveInTree(child, selectedId));
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2471
|
+
/** Chevron down SVG */
|
|
2472
|
+
function ChevronDown({ className }: { className?: string }) {
|
|
2473
|
+
return (
|
|
2474
|
+
<svg
|
|
2475
|
+
className={className}
|
|
2476
|
+
width="12"
|
|
2477
|
+
height="12"
|
|
2478
|
+
viewBox="0 0 12 12"
|
|
2479
|
+
fill="none"
|
|
2480
|
+
stroke="currentColor"
|
|
2481
|
+
strokeWidth="2"
|
|
2482
|
+
strokeLinecap="round"
|
|
2483
|
+
strokeLinejoin="round"
|
|
2484
|
+
>
|
|
2485
|
+
<path d="M3 4.5L6 7.5L9 4.5" />
|
|
2486
|
+
</svg>
|
|
2487
|
+
);
|
|
2488
|
+
}
|
|
2489
|
+
|
|
2490
|
+
/** Recursive dropdown items for nested categories */
|
|
2491
|
+
function CategoryDropdownItems({
|
|
2492
|
+
children,
|
|
2493
|
+
depth,
|
|
2494
|
+
selectedId,
|
|
2495
|
+
onSelect,
|
|
2496
|
+
}: {
|
|
2497
|
+
children: CategoryNode[];
|
|
2498
|
+
depth: number;
|
|
2499
|
+
selectedId: string;
|
|
2500
|
+
onSelect: (id: string) => void;
|
|
2501
|
+
}) {
|
|
2502
|
+
return (
|
|
2503
|
+
<>
|
|
2504
|
+
{children.map((child) => (
|
|
2505
|
+
<div key={child.id}>
|
|
2506
|
+
<button
|
|
2507
|
+
onClick={() => onSelect(child.id)}
|
|
2508
|
+
className={cn(
|
|
2509
|
+
'w-full text-start px-4 py-2 text-sm transition-colors hover:bg-muted',
|
|
2510
|
+
selectedId === child.id && 'bg-primary/10 text-primary font-medium'
|
|
2511
|
+
)}
|
|
2512
|
+
style={{ paddingInlineStart: \`\${(depth + 1) * 16}px\` }}
|
|
2513
|
+
>
|
|
2514
|
+
{child.name}
|
|
2515
|
+
</button>
|
|
2516
|
+
{child.children.length > 0 && (
|
|
2517
|
+
<CategoryDropdownItems
|
|
2518
|
+
children={child.children}
|
|
2519
|
+
depth={depth + 1}
|
|
2520
|
+
selectedId={selectedId}
|
|
2521
|
+
onSelect={onSelect}
|
|
2522
|
+
/>
|
|
2523
|
+
)}
|
|
2524
|
+
</div>
|
|
2525
|
+
))}
|
|
2526
|
+
</>
|
|
2527
|
+
);
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
/** Category chip with dropdown for subcategories */
|
|
2531
|
+
function CategoryChip({
|
|
2532
|
+
category,
|
|
2533
|
+
selectedId,
|
|
2534
|
+
onSelect,
|
|
2535
|
+
tc,
|
|
2536
|
+
}: {
|
|
2537
|
+
category: CategoryNode;
|
|
2538
|
+
selectedId: string;
|
|
2539
|
+
onSelect: (id: string) => void;
|
|
2540
|
+
tc: (key: string) => string;
|
|
2541
|
+
}) {
|
|
2542
|
+
const [open, setOpen] = useState(false);
|
|
2543
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
2544
|
+
const hasChildren = category.children.length > 0;
|
|
2545
|
+
const isActive = isActiveInTree(category, selectedId);
|
|
2546
|
+
|
|
2547
|
+
// Find the display name for the selected subcategory
|
|
2548
|
+
function findName(nodes: CategoryNode[], id: string): string | null {
|
|
2549
|
+
for (const n of nodes) {
|
|
2550
|
+
if (n.id === id) return n.name;
|
|
2551
|
+
const found = findName(n.children, id);
|
|
2552
|
+
if (found) return found;
|
|
2553
|
+
}
|
|
2554
|
+
return null;
|
|
2555
|
+
}
|
|
2556
|
+
|
|
2557
|
+
const selectedChildName =
|
|
2558
|
+
isActive && selectedId !== category.id ? findName(category.children, selectedId) : null;
|
|
2559
|
+
|
|
2560
|
+
// Close dropdown on outside click
|
|
2561
|
+
useEffect(() => {
|
|
2562
|
+
if (!open) return;
|
|
2563
|
+
function handleClick(e: MouseEvent) {
|
|
2564
|
+
if (ref.current && !ref.current.contains(e.target as Node)) {
|
|
2565
|
+
setOpen(false);
|
|
2566
|
+
}
|
|
2567
|
+
}
|
|
2568
|
+
document.addEventListener('mousedown', handleClick);
|
|
2569
|
+
return () => document.removeEventListener('mousedown', handleClick);
|
|
2570
|
+
}, [open]);
|
|
2571
|
+
|
|
2572
|
+
if (!hasChildren) {
|
|
2573
|
+
return (
|
|
2574
|
+
<button
|
|
2575
|
+
onClick={() => onSelect(category.id)}
|
|
2576
|
+
className={cn(
|
|
2577
|
+
'rounded-full border px-3 py-1.5 text-sm transition-colors',
|
|
2578
|
+
selectedId === category.id
|
|
2579
|
+
? 'bg-primary text-primary-foreground border-primary'
|
|
2580
|
+
: 'border-border text-muted-foreground hover:border-primary hover:text-foreground'
|
|
2581
|
+
)}
|
|
2582
|
+
>
|
|
2583
|
+
{category.name}
|
|
2584
|
+
</button>
|
|
2585
|
+
);
|
|
2586
|
+
}
|
|
2587
|
+
|
|
2588
|
+
return (
|
|
2589
|
+
<div ref={ref} className="relative">
|
|
2590
|
+
<button
|
|
2591
|
+
onClick={() => setOpen((prev) => !prev)}
|
|
2592
|
+
className={cn(
|
|
2593
|
+
'inline-flex items-center gap-1 rounded-full border px-3 py-1.5 text-sm transition-colors',
|
|
2594
|
+
isActive
|
|
2595
|
+
? 'bg-primary text-primary-foreground border-primary'
|
|
2596
|
+
: 'border-border text-muted-foreground hover:border-primary hover:text-foreground'
|
|
2597
|
+
)}
|
|
2598
|
+
>
|
|
2599
|
+
{category.name}
|
|
2600
|
+
{selectedChildName && (
|
|
2601
|
+
<span className="opacity-80">
|
|
2602
|
+
{'\xB7'} {selectedChildName}
|
|
2603
|
+
</span>
|
|
2604
|
+
)}
|
|
2605
|
+
<ChevronDown
|
|
2606
|
+
className={cn('transition-transform ms-0.5', open && 'rotate-180')}
|
|
2607
|
+
/>
|
|
2608
|
+
</button>
|
|
2609
|
+
|
|
2610
|
+
{open && (
|
|
2611
|
+
<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">
|
|
2612
|
+
{/* "All in [category]" option */}
|
|
2613
|
+
<button
|
|
2614
|
+
onClick={() => {
|
|
2615
|
+
onSelect(category.id);
|
|
2616
|
+
setOpen(false);
|
|
2617
|
+
}}
|
|
2618
|
+
className={cn(
|
|
2619
|
+
'w-full text-start px-4 py-2 text-sm font-medium transition-colors hover:bg-muted',
|
|
2620
|
+
selectedId === category.id && 'bg-primary/10 text-primary'
|
|
2621
|
+
)}
|
|
2622
|
+
>
|
|
2623
|
+
{tc('all')} {category.name}
|
|
2624
|
+
</button>
|
|
2625
|
+
<div className="bg-border mx-2 h-px" />
|
|
2626
|
+
{/* Recursive children */}
|
|
2627
|
+
<div
|
|
2628
|
+
onClick={() => setOpen(false)}
|
|
2629
|
+
>
|
|
2630
|
+
<CategoryDropdownItems
|
|
2631
|
+
children={category.children}
|
|
2632
|
+
depth={0}
|
|
2633
|
+
selectedId={selectedId}
|
|
2634
|
+
onSelect={onSelect}
|
|
2635
|
+
/>
|
|
2636
|
+
</div>
|
|
2637
|
+
</div>
|
|
2638
|
+
)}
|
|
2639
|
+
</div>
|
|
2640
|
+
);
|
|
2447
2641
|
}
|
|
2448
2642
|
|
|
2449
2643
|
function ProductsContent() {
|
|
@@ -2462,18 +2656,18 @@ function ProductsContent() {
|
|
|
2462
2656
|
const [page, setPage] = useState(1);
|
|
2463
2657
|
const [totalPages, setTotalPages] = useState(1);
|
|
2464
2658
|
const [total, setTotal] = useState(0);
|
|
2465
|
-
const [categories, setCategories] = useState<
|
|
2659
|
+
const [categories, setCategories] = useState<CategoryNode[]>([]);
|
|
2466
2660
|
|
|
2467
2661
|
const sortIndex = parseInt(sortParam, 10) || 0;
|
|
2468
2662
|
const currentSort = sortOptions[sortIndex] || sortOptions[0];
|
|
2469
2663
|
|
|
2470
|
-
// Load categories
|
|
2664
|
+
// Load categories (keep tree structure)
|
|
2471
2665
|
useEffect(() => {
|
|
2472
2666
|
async function loadCategories() {
|
|
2473
2667
|
try {
|
|
2474
2668
|
const client = getClient();
|
|
2475
2669
|
const result = await client.getCategories();
|
|
2476
|
-
setCategories(result.categories
|
|
2670
|
+
setCategories(result.categories as CategoryNode[]);
|
|
2477
2671
|
} catch {
|
|
2478
2672
|
// Categories endpoint may not be available in all modes
|
|
2479
2673
|
}
|
|
@@ -2542,6 +2736,10 @@ function ProductsContent() {
|
|
|
2542
2736
|
router.push(\`/products?\${params.toString()}\`);
|
|
2543
2737
|
}
|
|
2544
2738
|
|
|
2739
|
+
function handleCategorySelect(id: string) {
|
|
2740
|
+
updateParam('category', id);
|
|
2741
|
+
}
|
|
2742
|
+
|
|
2545
2743
|
return (
|
|
2546
2744
|
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
|
2547
2745
|
{/* Page Header */}
|
|
@@ -2573,18 +2771,13 @@ function ProductsContent() {
|
|
|
2573
2771
|
{tc('all')}
|
|
2574
2772
|
</button>
|
|
2575
2773
|
{categories.map((cat) => (
|
|
2576
|
-
<
|
|
2774
|
+
<CategoryChip
|
|
2577
2775
|
key={cat.id}
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
: 'border-border text-muted-foreground hover:border-primary hover:text-foreground'
|
|
2584
|
-
)}
|
|
2585
|
-
>
|
|
2586
|
-
{cat.name}
|
|
2587
|
-
</button>
|
|
2776
|
+
category={cat}
|
|
2777
|
+
selectedId={categoryId}
|
|
2778
|
+
onSelect={handleCategorySelect}
|
|
2779
|
+
tc={tc as (key: string) => string}
|
|
2780
|
+
/>
|
|
2588
2781
|
))}
|
|
2589
2782
|
</div>
|
|
2590
2783
|
)}
|
|
@@ -2666,7 +2859,18 @@ import { useEffect, useState, useMemo } from 'react';
|
|
|
2666
2859
|
import { useParams } from 'next/navigation';
|
|
2667
2860
|
import Image from 'next/image';
|
|
2668
2861
|
import Link from 'next/link';
|
|
2669
|
-
import type {
|
|
2862
|
+
import type {
|
|
2863
|
+
Product,
|
|
2864
|
+
ProductVariant,
|
|
2865
|
+
ProductImage,
|
|
2866
|
+
ProductMetafield,
|
|
2867
|
+
ProductRecommendationsResponse,
|
|
2868
|
+
DownloadFile,
|
|
2869
|
+
} from 'brainerce';
|
|
2870
|
+
|
|
2871
|
+
type ProductWithRecommendations = Product & {
|
|
2872
|
+
recommendations?: ProductRecommendationsResponse;
|
|
2873
|
+
};
|
|
2670
2874
|
import { getProductPriceInfo, getDescriptionContent } from 'brainerce';
|
|
2671
2875
|
import { getClient } from '@/lib/brainerce';
|
|
2672
2876
|
import { useCart } from '@/providers/store-provider';
|
|
@@ -2674,6 +2878,7 @@ import { PriceDisplay } from '@/components/shared/price-display';
|
|
|
2674
2878
|
import { LoadingSpinner } from '@/components/shared/loading-spinner';
|
|
2675
2879
|
import { VariantSelector } from '@/components/products/variant-selector';
|
|
2676
2880
|
import { StockBadge } from '@/components/products/stock-badge';
|
|
2881
|
+
import { RecommendationSection } from '@/components/products/recommendation-section';
|
|
2677
2882
|
import { useTranslations } from '@/lib/translations';
|
|
2678
2883
|
import { cn } from '@/lib/utils';
|
|
2679
2884
|
|
|
@@ -2767,7 +2972,7 @@ export default function ProductDetailPage() {
|
|
|
2767
2972
|
const { refreshCart } = useCart();
|
|
2768
2973
|
const t = useTranslations('productDetail');
|
|
2769
2974
|
const tc = useTranslations('common');
|
|
2770
|
-
const [product, setProduct] = useState<
|
|
2975
|
+
const [product, setProduct] = useState<ProductWithRecommendations | null>(null);
|
|
2771
2976
|
const [loading, setLoading] = useState(true);
|
|
2772
2977
|
const [error, setError] = useState<string | null>(null);
|
|
2773
2978
|
const [selectedVariant, setSelectedVariant] = useState<ProductVariant | null>(null);
|
|
@@ -2776,6 +2981,8 @@ export default function ProductDetailPage() {
|
|
|
2776
2981
|
const [addingToCart, setAddingToCart] = useState(false);
|
|
2777
2982
|
const [addedMessage, setAddedMessage] = useState(false);
|
|
2778
2983
|
|
|
2984
|
+
const recommendations = product?.recommendations ?? null;
|
|
2985
|
+
|
|
2779
2986
|
// Load product
|
|
2780
2987
|
useEffect(() => {
|
|
2781
2988
|
async function load() {
|
|
@@ -2784,7 +2991,7 @@ export default function ProductDetailPage() {
|
|
|
2784
2991
|
setError(null);
|
|
2785
2992
|
const client = getClient();
|
|
2786
2993
|
const p = await client.getProductBySlug(slug);
|
|
2787
|
-
setProduct(p);
|
|
2994
|
+
setProduct(p as ProductWithRecommendations);
|
|
2788
2995
|
|
|
2789
2996
|
// Auto-select first variant
|
|
2790
2997
|
if (p.variants && p.variants.length > 0) {
|
|
@@ -2990,8 +3197,43 @@ export default function ProductDetailPage() {
|
|
|
2990
3197
|
size="lg"
|
|
2991
3198
|
/>
|
|
2992
3199
|
|
|
2993
|
-
{/* Stock */}
|
|
2994
|
-
|
|
3200
|
+
{/* Stock / Digital badge */}
|
|
3201
|
+
{product.isDownloadable ? (
|
|
3202
|
+
<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">
|
|
3203
|
+
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
3204
|
+
<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" />
|
|
3205
|
+
</svg>
|
|
3206
|
+
{t('instantDownload')}
|
|
3207
|
+
</span>
|
|
3208
|
+
) : (
|
|
3209
|
+
<StockBadge inventory={inventory} lowStockThreshold={5} />
|
|
3210
|
+
)}
|
|
3211
|
+
|
|
3212
|
+
{/* Downloadable files info */}
|
|
3213
|
+
{product.isDownloadable && product.downloads && product.downloads.length > 0 && (
|
|
3214
|
+
<div className="bg-muted/50 rounded-lg border p-4">
|
|
3215
|
+
<p className="text-foreground mb-2 text-sm font-medium">
|
|
3216
|
+
{t('filesIncluded', { count: product.downloads.length })}
|
|
3217
|
+
</p>
|
|
3218
|
+
<ul className="space-y-1.5">
|
|
3219
|
+
{product.downloads.map((file: DownloadFile) => (
|
|
3220
|
+
<li key={file.id} className="text-muted-foreground flex items-center gap-2 text-sm">
|
|
3221
|
+
<svg className="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
3222
|
+
<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" />
|
|
3223
|
+
</svg>
|
|
3224
|
+
<span className="truncate">{file.name}</span>
|
|
3225
|
+
{file.size && (
|
|
3226
|
+
<span className="flex-shrink-0 text-xs">
|
|
3227
|
+
({file.size < 1024 * 1024
|
|
3228
|
+
? \`\${(file.size / 1024).toFixed(0)} KB\`
|
|
3229
|
+
: \`\${(file.size / (1024 * 1024)).toFixed(1)} MB\`})
|
|
3230
|
+
</span>
|
|
3231
|
+
)}
|
|
3232
|
+
</li>
|
|
3233
|
+
))}
|
|
3234
|
+
</ul>
|
|
3235
|
+
</div>
|
|
3236
|
+
)}
|
|
2995
3237
|
|
|
2996
3238
|
{/* Variant Selector */}
|
|
2997
3239
|
{product.type === 'VARIABLE' && product.variants && product.variants.length > 0 && (
|
|
@@ -3055,6 +3297,13 @@ export default function ProductDetailPage() {
|
|
|
3055
3297
|
</button>
|
|
3056
3298
|
</div>
|
|
3057
3299
|
|
|
3300
|
+
{/* Download after purchase note */}
|
|
3301
|
+
{product.isDownloadable && (
|
|
3302
|
+
<p className="text-muted-foreground text-sm">
|
|
3303
|
+
{t('downloadAfterPurchase')}
|
|
3304
|
+
</p>
|
|
3305
|
+
)}
|
|
3306
|
+
|
|
3058
3307
|
{/* Description */}
|
|
3059
3308
|
{description && (
|
|
3060
3309
|
<div className="border-border border-t pt-4">
|
|
@@ -3092,19 +3341,41 @@ export default function ProductDetailPage() {
|
|
|
3092
3341
|
)}
|
|
3093
3342
|
</div>
|
|
3094
3343
|
</div>
|
|
3344
|
+
|
|
3345
|
+
{/* Upsells \u2014 premium alternatives (product page) */}
|
|
3346
|
+
{recommendations?.upsells && recommendations.upsells.length > 0 && (
|
|
3347
|
+
<RecommendationSection
|
|
3348
|
+
title={t('upgradeYourChoice')}
|
|
3349
|
+
items={recommendations.upsells}
|
|
3350
|
+
className="mt-12"
|
|
3351
|
+
/>
|
|
3352
|
+
)}
|
|
3353
|
+
|
|
3354
|
+
{/* Related products \u2014 similar items (bottom of product page) */}
|
|
3355
|
+
{recommendations?.related && recommendations.related.length > 0 && (
|
|
3356
|
+
<RecommendationSection
|
|
3357
|
+
title={t('similarProducts')}
|
|
3358
|
+
items={recommendations.related}
|
|
3359
|
+
className="mt-12"
|
|
3360
|
+
/>
|
|
3361
|
+
)}
|
|
3095
3362
|
</div>
|
|
3096
3363
|
);
|
|
3097
3364
|
}
|
|
3098
3365
|
`,
|
|
3099
3366
|
"src/app/cart/page.tsx": `'use client';
|
|
3100
3367
|
|
|
3368
|
+
import { useEffect, useState } from 'react';
|
|
3101
3369
|
import Link from 'next/link';
|
|
3370
|
+
import type { CartRecommendationsResponse } from 'brainerce';
|
|
3371
|
+
import { getClient } from '@/lib/brainerce';
|
|
3102
3372
|
import { useCart } from '@/providers/store-provider';
|
|
3103
3373
|
import { CartItem } from '@/components/cart/cart-item';
|
|
3104
3374
|
import { CartSummary } from '@/components/cart/cart-summary';
|
|
3105
3375
|
import { CouponInput } from '@/components/cart/coupon-input';
|
|
3106
3376
|
import { CartNudges } from '@/components/cart/cart-nudges';
|
|
3107
3377
|
import { ReservationCountdown } from '@/components/cart/reservation-countdown';
|
|
3378
|
+
import { CartRecommendationSection } from '@/components/products/recommendation-section';
|
|
3108
3379
|
import { LoadingSpinner } from '@/components/shared/loading-spinner';
|
|
3109
3380
|
import { useTranslations } from '@/lib/translations';
|
|
3110
3381
|
|
|
@@ -3112,6 +3383,17 @@ export default function CartPage() {
|
|
|
3112
3383
|
const { cart, cartLoading, refreshCart, itemCount } = useCart();
|
|
3113
3384
|
const t = useTranslations('cart');
|
|
3114
3385
|
const tc = useTranslations('common');
|
|
3386
|
+
const [cartRecs, setCartRecs] = useState<CartRecommendationsResponse | null>(null);
|
|
3387
|
+
|
|
3388
|
+
// Load cross-sell recommendations when cart changes
|
|
3389
|
+
useEffect(() => {
|
|
3390
|
+
if (!cart?.id || cart.items.length === 0) {
|
|
3391
|
+
setCartRecs(null);
|
|
3392
|
+
return;
|
|
3393
|
+
}
|
|
3394
|
+
const client = getClient();
|
|
3395
|
+
client.getCartRecommendations(cart.id, 4).then(setCartRecs).catch(() => {});
|
|
3396
|
+
}, [cart?.id, cart?.items.length]);
|
|
3115
3397
|
|
|
3116
3398
|
if (cartLoading) {
|
|
3117
3399
|
return (
|
|
@@ -3203,6 +3485,15 @@ export default function CartPage() {
|
|
|
3203
3485
|
</div>
|
|
3204
3486
|
</div>
|
|
3205
3487
|
</div>
|
|
3488
|
+
|
|
3489
|
+
{/* Cross-sell recommendations */}
|
|
3490
|
+
{cartRecs?.recommendations && cartRecs.recommendations.length > 0 && (
|
|
3491
|
+
<CartRecommendationSection
|
|
3492
|
+
title={t('youMightAlsoNeed')}
|
|
3493
|
+
items={cartRecs.recommendations}
|
|
3494
|
+
className="mt-10"
|
|
3495
|
+
/>
|
|
3496
|
+
)}
|
|
3206
3497
|
</div>
|
|
3207
3498
|
);
|
|
3208
3499
|
}
|
|
@@ -4747,6 +5038,12 @@ async function proxyRequest(
|
|
|
4747
5038
|
'Content-Type': 'application/json',
|
|
4748
5039
|
};
|
|
4749
5040
|
|
|
5041
|
+
// Forward Origin/Referer so backend BrowserOriginGuard accepts proxied requests
|
|
5042
|
+
const origin = request.headers.get('origin');
|
|
5043
|
+
const referer = request.headers.get('referer');
|
|
5044
|
+
if (origin) headers['Origin'] = origin;
|
|
5045
|
+
if (referer) headers['Referer'] = referer;
|
|
5046
|
+
|
|
4750
5047
|
// Forward SDK version header if present
|
|
4751
5048
|
const sdkVersion = request.headers.get('x-sdk-version');
|
|
4752
5049
|
if (sdkVersion) {
|
|
@@ -5032,6 +5329,7 @@ interface ProductCardProps {
|
|
|
5032
5329
|
|
|
5033
5330
|
export function ProductCard({ product, className }: ProductCardProps) {
|
|
5034
5331
|
const t = useTranslations('common');
|
|
5332
|
+
const tp = useTranslations('productDetail');
|
|
5035
5333
|
const { price, originalPrice, isOnSale } = getProductPriceInfo(product);
|
|
5036
5334
|
const mainImage = product.images?.[0];
|
|
5037
5335
|
const imageUrl = mainImage?.url || null;
|
|
@@ -5076,6 +5374,11 @@ export function ProductCard({ product, className }: ProductCardProps) {
|
|
|
5076
5374
|
</span>
|
|
5077
5375
|
)}
|
|
5078
5376
|
<DiscountBadge discount={product.discount} />
|
|
5377
|
+
{product.isDownloadable && (
|
|
5378
|
+
<span className="bg-primary text-primary-foreground rounded px-2 py-1 text-xs font-bold">
|
|
5379
|
+
{tp('digitalProduct')}
|
|
5380
|
+
</span>
|
|
5381
|
+
)}
|
|
5079
5382
|
</div>
|
|
5080
5383
|
</div>
|
|
5081
5384
|
|
|
@@ -7411,10 +7714,11 @@ export function ProfileSection({ profile, onProfileUpdate, className }: ProfileS
|
|
|
7411
7714
|
`,
|
|
7412
7715
|
"src/components/account/order-history.tsx": `'use client';
|
|
7413
7716
|
|
|
7414
|
-
import { useState } from 'react';
|
|
7717
|
+
import { useState, useEffect } from 'react';
|
|
7415
7718
|
import Image from 'next/image';
|
|
7416
|
-
import type { Order, OrderStatus } from 'brainerce';
|
|
7719
|
+
import type { Order, OrderStatus, OrderDownloadLink } from 'brainerce';
|
|
7417
7720
|
import { formatPrice } from 'brainerce';
|
|
7721
|
+
import { getClient } from '@/lib/brainerce';
|
|
7418
7722
|
import { useTranslations } from '@/lib/translations';
|
|
7419
7723
|
import { cn } from '@/lib/utils';
|
|
7420
7724
|
|
|
@@ -7598,6 +7902,11 @@ function OrderCard({ order }: { order: Order }) {
|
|
|
7598
7902
|
</div>
|
|
7599
7903
|
))}
|
|
7600
7904
|
|
|
7905
|
+
{/* Downloads section */}
|
|
7906
|
+
{order.hasDownloads && (
|
|
7907
|
+
<OrderDownloads orderId={order.id} />
|
|
7908
|
+
)}
|
|
7909
|
+
|
|
7601
7910
|
<OrderFinancialSummary order={order} currency={currency} />
|
|
7602
7911
|
</div>
|
|
7603
7912
|
)}
|
|
@@ -7605,6 +7914,71 @@ function OrderCard({ order }: { order: Order }) {
|
|
|
7605
7914
|
);
|
|
7606
7915
|
}
|
|
7607
7916
|
|
|
7917
|
+
function OrderDownloads({ orderId }: { orderId: string }) {
|
|
7918
|
+
const t = useTranslations('account');
|
|
7919
|
+
const [downloads, setDownloads] = useState<OrderDownloadLink[] | null>(null);
|
|
7920
|
+
const [loading, setLoading] = useState(true);
|
|
7921
|
+
|
|
7922
|
+
useEffect(() => {
|
|
7923
|
+
let cancelled = false;
|
|
7924
|
+
async function fetch() {
|
|
7925
|
+
try {
|
|
7926
|
+
const client = getClient();
|
|
7927
|
+
const links = await client.getOrderDownloads(orderId);
|
|
7928
|
+
if (!cancelled) setDownloads(links);
|
|
7929
|
+
} catch {
|
|
7930
|
+
if (!cancelled) setDownloads([]);
|
|
7931
|
+
} finally {
|
|
7932
|
+
if (!cancelled) setLoading(false);
|
|
7933
|
+
}
|
|
7934
|
+
}
|
|
7935
|
+
fetch();
|
|
7936
|
+
return () => { cancelled = true; };
|
|
7937
|
+
}, [orderId]);
|
|
7938
|
+
|
|
7939
|
+
if (loading) {
|
|
7940
|
+
return (
|
|
7941
|
+
<div className="border-border border-t pt-2">
|
|
7942
|
+
<p className="text-muted-foreground animate-pulse text-xs">{t('downloads')}...</p>
|
|
7943
|
+
</div>
|
|
7944
|
+
);
|
|
7945
|
+
}
|
|
7946
|
+
|
|
7947
|
+
if (!downloads || downloads.length === 0) return null;
|
|
7948
|
+
|
|
7949
|
+
return (
|
|
7950
|
+
<div className="border-border space-y-2 border-t pt-2">
|
|
7951
|
+
<p className="text-foreground text-sm font-medium">{t('downloads')}</p>
|
|
7952
|
+
{downloads.map((link, idx) => (
|
|
7953
|
+
<div key={idx} className="flex items-center gap-3">
|
|
7954
|
+
<div className="min-w-0 flex-1">
|
|
7955
|
+
<p className="text-foreground truncate text-sm">{link.fileName}</p>
|
|
7956
|
+
<p className="text-muted-foreground text-xs">
|
|
7957
|
+
{link.productName}
|
|
7958
|
+
{' \xB7 '}
|
|
7959
|
+
{link.downloadLimit != null
|
|
7960
|
+
? t('downloadsRemaining', { used: link.downloadsUsed, limit: link.downloadLimit })
|
|
7961
|
+
: t('unlimitedDownloads')}
|
|
7962
|
+
{' \xB7 '}
|
|
7963
|
+
{link.expiresAt
|
|
7964
|
+
? t('expiresAt', { date: new Date(link.expiresAt).toLocaleDateString() })
|
|
7965
|
+
: t('noExpiry')}
|
|
7966
|
+
</p>
|
|
7967
|
+
</div>
|
|
7968
|
+
<a
|
|
7969
|
+
href={link.downloadUrl}
|
|
7970
|
+
target="_blank"
|
|
7971
|
+
rel="noopener noreferrer"
|
|
7972
|
+
className="bg-primary text-primary-foreground flex-shrink-0 rounded px-3 py-1 text-xs font-medium hover:opacity-90"
|
|
7973
|
+
>
|
|
7974
|
+
{t('downloadFile')}
|
|
7975
|
+
</a>
|
|
7976
|
+
</div>
|
|
7977
|
+
))}
|
|
7978
|
+
</div>
|
|
7979
|
+
);
|
|
7980
|
+
}
|
|
7981
|
+
|
|
7608
7982
|
function OrderFinancialSummary({ order, currency }: { order: Order; currency: string }) {
|
|
7609
7983
|
const tc = useTranslations('common');
|
|
7610
7984
|
const totalAmount = order.totalAmount || order.total || '0';
|
|
@@ -8486,7 +8860,7 @@ var DETAILED = `# Required Pages \u2014 Detailed Guide
|
|
|
8486
8860
|
- Stock badge
|
|
8487
8861
|
- Discount badge (\`getProductDiscountBadge()\`)
|
|
8488
8862
|
- Add to cart button (disabled when \`!canPurchase\`)
|
|
8489
|
-
- Product recommendations (\`
|
|
8863
|
+
- Product recommendations (from \`product.recommendations\` \u2014 embedded in product response)
|
|
8490
8864
|
- Use \`client.getProductBySlug(slug)\`
|
|
8491
8865
|
|
|
8492
8866
|
## 4. Cart (\`/cart\`)
|