@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/bin/stdio.js
CHANGED
|
@@ -713,20 +713,25 @@ Display: banners in header, badges on product cards (strikethrough + discounted
|
|
|
713
713
|
function getRecommendationsSection() {
|
|
714
714
|
return `## Product Recommendations (Cross-Sells & Upsells)
|
|
715
715
|
|
|
716
|
+
Recommendations come **embedded in the product response** \u2014 no extra API call needed for product pages.
|
|
717
|
+
|
|
716
718
|
\`\`\`typescript
|
|
717
719
|
import type { ProductRecommendation, ProductRecommendationsResponse, CartRecommendationsResponse } from 'brainerce';
|
|
718
720
|
|
|
719
|
-
// Product page \u2014
|
|
720
|
-
const
|
|
721
|
-
|
|
721
|
+
// Product page \u2014 recommendations are embedded in the product response
|
|
722
|
+
const product = await client.getProductBySlug('some-slug');
|
|
723
|
+
const recs = (product as any).recommendations as ProductRecommendationsResponse | undefined;
|
|
724
|
+
// recs?.upsells \u2014 premium alternatives (show on product page)
|
|
725
|
+
// recs?.related \u2014 similar products (show at bottom of product page)
|
|
726
|
+
// recs?.crossSells \u2014 complementary products (typically used on cart page)
|
|
722
727
|
|
|
723
|
-
// Cart page \u2014 cross-sell suggestions for cart items
|
|
728
|
+
// Cart page \u2014 cross-sell suggestions for cart items (separate call)
|
|
724
729
|
const cartRecs: CartRecommendationsResponse = await client.getCartRecommendations(cartId, 4);
|
|
725
|
-
// cartRecs.recommendations \u2014 deduplicated cross-sells
|
|
730
|
+
// cartRecs.recommendations \u2014 deduplicated cross-sells (excludes items already in cart)
|
|
726
731
|
\`\`\`
|
|
727
732
|
|
|
728
733
|
ProductRecommendation: \`id\`, \`name\`, \`slug\`, \`basePrice\`, \`salePrice\`, \`images\`, \`type\`, \`inventory\`, \`relationType\`.
|
|
729
|
-
Display
|
|
734
|
+
Display upsells + related on product pages, cross-sells on cart page.`;
|
|
730
735
|
}
|
|
731
736
|
function getInventorySection() {
|
|
732
737
|
return `## Inventory, Stock Display & Reservation Countdown
|
|
@@ -2400,7 +2405,7 @@ export default function RootLayout({
|
|
|
2400
2405
|
`,
|
|
2401
2406
|
"src/app/products/page.tsx": `'use client';
|
|
2402
2407
|
|
|
2403
|
-
import { Suspense, useEffect, useState, useCallback } from 'react';
|
|
2408
|
+
import { Suspense, useEffect, useState, useCallback, useRef } from 'react';
|
|
2404
2409
|
import { useSearchParams, useRouter } from 'next/navigation';
|
|
2405
2410
|
import type { Product } from 'brainerce';
|
|
2406
2411
|
import type { ProductQueryParams } from 'brainerce';
|
|
@@ -2426,9 +2431,198 @@ const sortOptions: SortOption[] = [
|
|
|
2426
2431
|
{ labelKey: 'sortPriceHigh', sortBy: 'price', sortOrder: 'desc' },
|
|
2427
2432
|
];
|
|
2428
2433
|
|
|
2429
|
-
interface
|
|
2434
|
+
interface CategoryNode {
|
|
2430
2435
|
id: string;
|
|
2431
2436
|
name: string;
|
|
2437
|
+
parentId?: string | null;
|
|
2438
|
+
children: CategoryNode[];
|
|
2439
|
+
}
|
|
2440
|
+
|
|
2441
|
+
/** Collect all descendant IDs (including self) */
|
|
2442
|
+
function getAllDescendantIds(node: CategoryNode): string[] {
|
|
2443
|
+
const ids = [node.id];
|
|
2444
|
+
for (const child of node.children) {
|
|
2445
|
+
ids.push(...getAllDescendantIds(child));
|
|
2446
|
+
}
|
|
2447
|
+
return ids;
|
|
2448
|
+
}
|
|
2449
|
+
|
|
2450
|
+
/** Check if a category or any of its descendants matches the selected ID */
|
|
2451
|
+
function isActiveInTree(node: CategoryNode, selectedId: string): boolean {
|
|
2452
|
+
if (node.id === selectedId) return true;
|
|
2453
|
+
return node.children.some((child) => isActiveInTree(child, selectedId));
|
|
2454
|
+
}
|
|
2455
|
+
|
|
2456
|
+
/** Chevron down SVG */
|
|
2457
|
+
function ChevronDown({ className }: { className?: string }) {
|
|
2458
|
+
return (
|
|
2459
|
+
<svg
|
|
2460
|
+
className={className}
|
|
2461
|
+
width="12"
|
|
2462
|
+
height="12"
|
|
2463
|
+
viewBox="0 0 12 12"
|
|
2464
|
+
fill="none"
|
|
2465
|
+
stroke="currentColor"
|
|
2466
|
+
strokeWidth="2"
|
|
2467
|
+
strokeLinecap="round"
|
|
2468
|
+
strokeLinejoin="round"
|
|
2469
|
+
>
|
|
2470
|
+
<path d="M3 4.5L6 7.5L9 4.5" />
|
|
2471
|
+
</svg>
|
|
2472
|
+
);
|
|
2473
|
+
}
|
|
2474
|
+
|
|
2475
|
+
/** Recursive dropdown items for nested categories */
|
|
2476
|
+
function CategoryDropdownItems({
|
|
2477
|
+
children,
|
|
2478
|
+
depth,
|
|
2479
|
+
selectedId,
|
|
2480
|
+
onSelect,
|
|
2481
|
+
}: {
|
|
2482
|
+
children: CategoryNode[];
|
|
2483
|
+
depth: number;
|
|
2484
|
+
selectedId: string;
|
|
2485
|
+
onSelect: (id: string) => void;
|
|
2486
|
+
}) {
|
|
2487
|
+
return (
|
|
2488
|
+
<>
|
|
2489
|
+
{children.map((child) => (
|
|
2490
|
+
<div key={child.id}>
|
|
2491
|
+
<button
|
|
2492
|
+
onClick={() => onSelect(child.id)}
|
|
2493
|
+
className={cn(
|
|
2494
|
+
'w-full text-start px-4 py-2 text-sm transition-colors hover:bg-muted',
|
|
2495
|
+
selectedId === child.id && 'bg-primary/10 text-primary font-medium'
|
|
2496
|
+
)}
|
|
2497
|
+
style={{ paddingInlineStart: \`\${(depth + 1) * 16}px\` }}
|
|
2498
|
+
>
|
|
2499
|
+
{child.name}
|
|
2500
|
+
</button>
|
|
2501
|
+
{child.children.length > 0 && (
|
|
2502
|
+
<CategoryDropdownItems
|
|
2503
|
+
children={child.children}
|
|
2504
|
+
depth={depth + 1}
|
|
2505
|
+
selectedId={selectedId}
|
|
2506
|
+
onSelect={onSelect}
|
|
2507
|
+
/>
|
|
2508
|
+
)}
|
|
2509
|
+
</div>
|
|
2510
|
+
))}
|
|
2511
|
+
</>
|
|
2512
|
+
);
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2515
|
+
/** Category chip with dropdown for subcategories */
|
|
2516
|
+
function CategoryChip({
|
|
2517
|
+
category,
|
|
2518
|
+
selectedId,
|
|
2519
|
+
onSelect,
|
|
2520
|
+
tc,
|
|
2521
|
+
}: {
|
|
2522
|
+
category: CategoryNode;
|
|
2523
|
+
selectedId: string;
|
|
2524
|
+
onSelect: (id: string) => void;
|
|
2525
|
+
tc: (key: string) => string;
|
|
2526
|
+
}) {
|
|
2527
|
+
const [open, setOpen] = useState(false);
|
|
2528
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
2529
|
+
const hasChildren = category.children.length > 0;
|
|
2530
|
+
const isActive = isActiveInTree(category, selectedId);
|
|
2531
|
+
|
|
2532
|
+
// Find the display name for the selected subcategory
|
|
2533
|
+
function findName(nodes: CategoryNode[], id: string): string | null {
|
|
2534
|
+
for (const n of nodes) {
|
|
2535
|
+
if (n.id === id) return n.name;
|
|
2536
|
+
const found = findName(n.children, id);
|
|
2537
|
+
if (found) return found;
|
|
2538
|
+
}
|
|
2539
|
+
return null;
|
|
2540
|
+
}
|
|
2541
|
+
|
|
2542
|
+
const selectedChildName =
|
|
2543
|
+
isActive && selectedId !== category.id ? findName(category.children, selectedId) : null;
|
|
2544
|
+
|
|
2545
|
+
// Close dropdown on outside click
|
|
2546
|
+
useEffect(() => {
|
|
2547
|
+
if (!open) return;
|
|
2548
|
+
function handleClick(e: MouseEvent) {
|
|
2549
|
+
if (ref.current && !ref.current.contains(e.target as Node)) {
|
|
2550
|
+
setOpen(false);
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
2553
|
+
document.addEventListener('mousedown', handleClick);
|
|
2554
|
+
return () => document.removeEventListener('mousedown', handleClick);
|
|
2555
|
+
}, [open]);
|
|
2556
|
+
|
|
2557
|
+
if (!hasChildren) {
|
|
2558
|
+
return (
|
|
2559
|
+
<button
|
|
2560
|
+
onClick={() => onSelect(category.id)}
|
|
2561
|
+
className={cn(
|
|
2562
|
+
'rounded-full border px-3 py-1.5 text-sm transition-colors',
|
|
2563
|
+
selectedId === category.id
|
|
2564
|
+
? 'bg-primary text-primary-foreground border-primary'
|
|
2565
|
+
: 'border-border text-muted-foreground hover:border-primary hover:text-foreground'
|
|
2566
|
+
)}
|
|
2567
|
+
>
|
|
2568
|
+
{category.name}
|
|
2569
|
+
</button>
|
|
2570
|
+
);
|
|
2571
|
+
}
|
|
2572
|
+
|
|
2573
|
+
return (
|
|
2574
|
+
<div ref={ref} className="relative">
|
|
2575
|
+
<button
|
|
2576
|
+
onClick={() => setOpen((prev) => !prev)}
|
|
2577
|
+
className={cn(
|
|
2578
|
+
'inline-flex items-center gap-1 rounded-full border px-3 py-1.5 text-sm transition-colors',
|
|
2579
|
+
isActive
|
|
2580
|
+
? 'bg-primary text-primary-foreground border-primary'
|
|
2581
|
+
: 'border-border text-muted-foreground hover:border-primary hover:text-foreground'
|
|
2582
|
+
)}
|
|
2583
|
+
>
|
|
2584
|
+
{category.name}
|
|
2585
|
+
{selectedChildName && (
|
|
2586
|
+
<span className="opacity-80">
|
|
2587
|
+
{'\xB7'} {selectedChildName}
|
|
2588
|
+
</span>
|
|
2589
|
+
)}
|
|
2590
|
+
<ChevronDown
|
|
2591
|
+
className={cn('transition-transform ms-0.5', open && 'rotate-180')}
|
|
2592
|
+
/>
|
|
2593
|
+
</button>
|
|
2594
|
+
|
|
2595
|
+
{open && (
|
|
2596
|
+
<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">
|
|
2597
|
+
{/* "All in [category]" option */}
|
|
2598
|
+
<button
|
|
2599
|
+
onClick={() => {
|
|
2600
|
+
onSelect(category.id);
|
|
2601
|
+
setOpen(false);
|
|
2602
|
+
}}
|
|
2603
|
+
className={cn(
|
|
2604
|
+
'w-full text-start px-4 py-2 text-sm font-medium transition-colors hover:bg-muted',
|
|
2605
|
+
selectedId === category.id && 'bg-primary/10 text-primary'
|
|
2606
|
+
)}
|
|
2607
|
+
>
|
|
2608
|
+
{tc('all')} {category.name}
|
|
2609
|
+
</button>
|
|
2610
|
+
<div className="bg-border mx-2 h-px" />
|
|
2611
|
+
{/* Recursive children */}
|
|
2612
|
+
<div
|
|
2613
|
+
onClick={() => setOpen(false)}
|
|
2614
|
+
>
|
|
2615
|
+
<CategoryDropdownItems
|
|
2616
|
+
children={category.children}
|
|
2617
|
+
depth={0}
|
|
2618
|
+
selectedId={selectedId}
|
|
2619
|
+
onSelect={onSelect}
|
|
2620
|
+
/>
|
|
2621
|
+
</div>
|
|
2622
|
+
</div>
|
|
2623
|
+
)}
|
|
2624
|
+
</div>
|
|
2625
|
+
);
|
|
2432
2626
|
}
|
|
2433
2627
|
|
|
2434
2628
|
function ProductsContent() {
|
|
@@ -2447,18 +2641,18 @@ function ProductsContent() {
|
|
|
2447
2641
|
const [page, setPage] = useState(1);
|
|
2448
2642
|
const [totalPages, setTotalPages] = useState(1);
|
|
2449
2643
|
const [total, setTotal] = useState(0);
|
|
2450
|
-
const [categories, setCategories] = useState<
|
|
2644
|
+
const [categories, setCategories] = useState<CategoryNode[]>([]);
|
|
2451
2645
|
|
|
2452
2646
|
const sortIndex = parseInt(sortParam, 10) || 0;
|
|
2453
2647
|
const currentSort = sortOptions[sortIndex] || sortOptions[0];
|
|
2454
2648
|
|
|
2455
|
-
// Load categories
|
|
2649
|
+
// Load categories (keep tree structure)
|
|
2456
2650
|
useEffect(() => {
|
|
2457
2651
|
async function loadCategories() {
|
|
2458
2652
|
try {
|
|
2459
2653
|
const client = getClient();
|
|
2460
2654
|
const result = await client.getCategories();
|
|
2461
|
-
setCategories(result.categories
|
|
2655
|
+
setCategories(result.categories as CategoryNode[]);
|
|
2462
2656
|
} catch {
|
|
2463
2657
|
// Categories endpoint may not be available in all modes
|
|
2464
2658
|
}
|
|
@@ -2527,6 +2721,10 @@ function ProductsContent() {
|
|
|
2527
2721
|
router.push(\`/products?\${params.toString()}\`);
|
|
2528
2722
|
}
|
|
2529
2723
|
|
|
2724
|
+
function handleCategorySelect(id: string) {
|
|
2725
|
+
updateParam('category', id);
|
|
2726
|
+
}
|
|
2727
|
+
|
|
2530
2728
|
return (
|
|
2531
2729
|
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
|
2532
2730
|
{/* Page Header */}
|
|
@@ -2558,18 +2756,13 @@ function ProductsContent() {
|
|
|
2558
2756
|
{tc('all')}
|
|
2559
2757
|
</button>
|
|
2560
2758
|
{categories.map((cat) => (
|
|
2561
|
-
<
|
|
2759
|
+
<CategoryChip
|
|
2562
2760
|
key={cat.id}
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
: 'border-border text-muted-foreground hover:border-primary hover:text-foreground'
|
|
2569
|
-
)}
|
|
2570
|
-
>
|
|
2571
|
-
{cat.name}
|
|
2572
|
-
</button>
|
|
2761
|
+
category={cat}
|
|
2762
|
+
selectedId={categoryId}
|
|
2763
|
+
onSelect={handleCategorySelect}
|
|
2764
|
+
tc={tc as (key: string) => string}
|
|
2765
|
+
/>
|
|
2573
2766
|
))}
|
|
2574
2767
|
</div>
|
|
2575
2768
|
)}
|
|
@@ -2651,7 +2844,18 @@ import { useEffect, useState, useMemo } from 'react';
|
|
|
2651
2844
|
import { useParams } from 'next/navigation';
|
|
2652
2845
|
import Image from 'next/image';
|
|
2653
2846
|
import Link from 'next/link';
|
|
2654
|
-
import type {
|
|
2847
|
+
import type {
|
|
2848
|
+
Product,
|
|
2849
|
+
ProductVariant,
|
|
2850
|
+
ProductImage,
|
|
2851
|
+
ProductMetafield,
|
|
2852
|
+
ProductRecommendationsResponse,
|
|
2853
|
+
DownloadFile,
|
|
2854
|
+
} from 'brainerce';
|
|
2855
|
+
|
|
2856
|
+
type ProductWithRecommendations = Product & {
|
|
2857
|
+
recommendations?: ProductRecommendationsResponse;
|
|
2858
|
+
};
|
|
2655
2859
|
import { getProductPriceInfo, getDescriptionContent } from 'brainerce';
|
|
2656
2860
|
import { getClient } from '@/lib/brainerce';
|
|
2657
2861
|
import { useCart } from '@/providers/store-provider';
|
|
@@ -2659,6 +2863,7 @@ import { PriceDisplay } from '@/components/shared/price-display';
|
|
|
2659
2863
|
import { LoadingSpinner } from '@/components/shared/loading-spinner';
|
|
2660
2864
|
import { VariantSelector } from '@/components/products/variant-selector';
|
|
2661
2865
|
import { StockBadge } from '@/components/products/stock-badge';
|
|
2866
|
+
import { RecommendationSection } from '@/components/products/recommendation-section';
|
|
2662
2867
|
import { useTranslations } from '@/lib/translations';
|
|
2663
2868
|
import { cn } from '@/lib/utils';
|
|
2664
2869
|
|
|
@@ -2752,7 +2957,7 @@ export default function ProductDetailPage() {
|
|
|
2752
2957
|
const { refreshCart } = useCart();
|
|
2753
2958
|
const t = useTranslations('productDetail');
|
|
2754
2959
|
const tc = useTranslations('common');
|
|
2755
|
-
const [product, setProduct] = useState<
|
|
2960
|
+
const [product, setProduct] = useState<ProductWithRecommendations | null>(null);
|
|
2756
2961
|
const [loading, setLoading] = useState(true);
|
|
2757
2962
|
const [error, setError] = useState<string | null>(null);
|
|
2758
2963
|
const [selectedVariant, setSelectedVariant] = useState<ProductVariant | null>(null);
|
|
@@ -2761,6 +2966,8 @@ export default function ProductDetailPage() {
|
|
|
2761
2966
|
const [addingToCart, setAddingToCart] = useState(false);
|
|
2762
2967
|
const [addedMessage, setAddedMessage] = useState(false);
|
|
2763
2968
|
|
|
2969
|
+
const recommendations = product?.recommendations ?? null;
|
|
2970
|
+
|
|
2764
2971
|
// Load product
|
|
2765
2972
|
useEffect(() => {
|
|
2766
2973
|
async function load() {
|
|
@@ -2769,7 +2976,7 @@ export default function ProductDetailPage() {
|
|
|
2769
2976
|
setError(null);
|
|
2770
2977
|
const client = getClient();
|
|
2771
2978
|
const p = await client.getProductBySlug(slug);
|
|
2772
|
-
setProduct(p);
|
|
2979
|
+
setProduct(p as ProductWithRecommendations);
|
|
2773
2980
|
|
|
2774
2981
|
// Auto-select first variant
|
|
2775
2982
|
if (p.variants && p.variants.length > 0) {
|
|
@@ -2975,8 +3182,43 @@ export default function ProductDetailPage() {
|
|
|
2975
3182
|
size="lg"
|
|
2976
3183
|
/>
|
|
2977
3184
|
|
|
2978
|
-
{/* Stock */}
|
|
2979
|
-
|
|
3185
|
+
{/* Stock / Digital badge */}
|
|
3186
|
+
{product.isDownloadable ? (
|
|
3187
|
+
<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">
|
|
3188
|
+
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
3189
|
+
<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" />
|
|
3190
|
+
</svg>
|
|
3191
|
+
{t('instantDownload')}
|
|
3192
|
+
</span>
|
|
3193
|
+
) : (
|
|
3194
|
+
<StockBadge inventory={inventory} lowStockThreshold={5} />
|
|
3195
|
+
)}
|
|
3196
|
+
|
|
3197
|
+
{/* Downloadable files info */}
|
|
3198
|
+
{product.isDownloadable && product.downloads && product.downloads.length > 0 && (
|
|
3199
|
+
<div className="bg-muted/50 rounded-lg border p-4">
|
|
3200
|
+
<p className="text-foreground mb-2 text-sm font-medium">
|
|
3201
|
+
{t('filesIncluded', { count: product.downloads.length })}
|
|
3202
|
+
</p>
|
|
3203
|
+
<ul className="space-y-1.5">
|
|
3204
|
+
{product.downloads.map((file: DownloadFile) => (
|
|
3205
|
+
<li key={file.id} className="text-muted-foreground flex items-center gap-2 text-sm">
|
|
3206
|
+
<svg className="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
3207
|
+
<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" />
|
|
3208
|
+
</svg>
|
|
3209
|
+
<span className="truncate">{file.name}</span>
|
|
3210
|
+
{file.size && (
|
|
3211
|
+
<span className="flex-shrink-0 text-xs">
|
|
3212
|
+
({file.size < 1024 * 1024
|
|
3213
|
+
? \`\${(file.size / 1024).toFixed(0)} KB\`
|
|
3214
|
+
: \`\${(file.size / (1024 * 1024)).toFixed(1)} MB\`})
|
|
3215
|
+
</span>
|
|
3216
|
+
)}
|
|
3217
|
+
</li>
|
|
3218
|
+
))}
|
|
3219
|
+
</ul>
|
|
3220
|
+
</div>
|
|
3221
|
+
)}
|
|
2980
3222
|
|
|
2981
3223
|
{/* Variant Selector */}
|
|
2982
3224
|
{product.type === 'VARIABLE' && product.variants && product.variants.length > 0 && (
|
|
@@ -3040,6 +3282,13 @@ export default function ProductDetailPage() {
|
|
|
3040
3282
|
</button>
|
|
3041
3283
|
</div>
|
|
3042
3284
|
|
|
3285
|
+
{/* Download after purchase note */}
|
|
3286
|
+
{product.isDownloadable && (
|
|
3287
|
+
<p className="text-muted-foreground text-sm">
|
|
3288
|
+
{t('downloadAfterPurchase')}
|
|
3289
|
+
</p>
|
|
3290
|
+
)}
|
|
3291
|
+
|
|
3043
3292
|
{/* Description */}
|
|
3044
3293
|
{description && (
|
|
3045
3294
|
<div className="border-border border-t pt-4">
|
|
@@ -3077,19 +3326,41 @@ export default function ProductDetailPage() {
|
|
|
3077
3326
|
)}
|
|
3078
3327
|
</div>
|
|
3079
3328
|
</div>
|
|
3329
|
+
|
|
3330
|
+
{/* Upsells \u2014 premium alternatives (product page) */}
|
|
3331
|
+
{recommendations?.upsells && recommendations.upsells.length > 0 && (
|
|
3332
|
+
<RecommendationSection
|
|
3333
|
+
title={t('upgradeYourChoice')}
|
|
3334
|
+
items={recommendations.upsells}
|
|
3335
|
+
className="mt-12"
|
|
3336
|
+
/>
|
|
3337
|
+
)}
|
|
3338
|
+
|
|
3339
|
+
{/* Related products \u2014 similar items (bottom of product page) */}
|
|
3340
|
+
{recommendations?.related && recommendations.related.length > 0 && (
|
|
3341
|
+
<RecommendationSection
|
|
3342
|
+
title={t('similarProducts')}
|
|
3343
|
+
items={recommendations.related}
|
|
3344
|
+
className="mt-12"
|
|
3345
|
+
/>
|
|
3346
|
+
)}
|
|
3080
3347
|
</div>
|
|
3081
3348
|
);
|
|
3082
3349
|
}
|
|
3083
3350
|
`,
|
|
3084
3351
|
"src/app/cart/page.tsx": `'use client';
|
|
3085
3352
|
|
|
3353
|
+
import { useEffect, useState } from 'react';
|
|
3086
3354
|
import Link from 'next/link';
|
|
3355
|
+
import type { CartRecommendationsResponse } from 'brainerce';
|
|
3356
|
+
import { getClient } from '@/lib/brainerce';
|
|
3087
3357
|
import { useCart } from '@/providers/store-provider';
|
|
3088
3358
|
import { CartItem } from '@/components/cart/cart-item';
|
|
3089
3359
|
import { CartSummary } from '@/components/cart/cart-summary';
|
|
3090
3360
|
import { CouponInput } from '@/components/cart/coupon-input';
|
|
3091
3361
|
import { CartNudges } from '@/components/cart/cart-nudges';
|
|
3092
3362
|
import { ReservationCountdown } from '@/components/cart/reservation-countdown';
|
|
3363
|
+
import { CartRecommendationSection } from '@/components/products/recommendation-section';
|
|
3093
3364
|
import { LoadingSpinner } from '@/components/shared/loading-spinner';
|
|
3094
3365
|
import { useTranslations } from '@/lib/translations';
|
|
3095
3366
|
|
|
@@ -3097,6 +3368,17 @@ export default function CartPage() {
|
|
|
3097
3368
|
const { cart, cartLoading, refreshCart, itemCount } = useCart();
|
|
3098
3369
|
const t = useTranslations('cart');
|
|
3099
3370
|
const tc = useTranslations('common');
|
|
3371
|
+
const [cartRecs, setCartRecs] = useState<CartRecommendationsResponse | null>(null);
|
|
3372
|
+
|
|
3373
|
+
// Load cross-sell recommendations when cart changes
|
|
3374
|
+
useEffect(() => {
|
|
3375
|
+
if (!cart?.id || cart.items.length === 0) {
|
|
3376
|
+
setCartRecs(null);
|
|
3377
|
+
return;
|
|
3378
|
+
}
|
|
3379
|
+
const client = getClient();
|
|
3380
|
+
client.getCartRecommendations(cart.id, 4).then(setCartRecs).catch(() => {});
|
|
3381
|
+
}, [cart?.id, cart?.items.length]);
|
|
3100
3382
|
|
|
3101
3383
|
if (cartLoading) {
|
|
3102
3384
|
return (
|
|
@@ -3188,6 +3470,15 @@ export default function CartPage() {
|
|
|
3188
3470
|
</div>
|
|
3189
3471
|
</div>
|
|
3190
3472
|
</div>
|
|
3473
|
+
|
|
3474
|
+
{/* Cross-sell recommendations */}
|
|
3475
|
+
{cartRecs?.recommendations && cartRecs.recommendations.length > 0 && (
|
|
3476
|
+
<CartRecommendationSection
|
|
3477
|
+
title={t('youMightAlsoNeed')}
|
|
3478
|
+
items={cartRecs.recommendations}
|
|
3479
|
+
className="mt-10"
|
|
3480
|
+
/>
|
|
3481
|
+
)}
|
|
3191
3482
|
</div>
|
|
3192
3483
|
);
|
|
3193
3484
|
}
|
|
@@ -4732,6 +5023,12 @@ async function proxyRequest(
|
|
|
4732
5023
|
'Content-Type': 'application/json',
|
|
4733
5024
|
};
|
|
4734
5025
|
|
|
5026
|
+
// Forward Origin/Referer so backend BrowserOriginGuard accepts proxied requests
|
|
5027
|
+
const origin = request.headers.get('origin');
|
|
5028
|
+
const referer = request.headers.get('referer');
|
|
5029
|
+
if (origin) headers['Origin'] = origin;
|
|
5030
|
+
if (referer) headers['Referer'] = referer;
|
|
5031
|
+
|
|
4735
5032
|
// Forward SDK version header if present
|
|
4736
5033
|
const sdkVersion = request.headers.get('x-sdk-version');
|
|
4737
5034
|
if (sdkVersion) {
|
|
@@ -5017,6 +5314,7 @@ interface ProductCardProps {
|
|
|
5017
5314
|
|
|
5018
5315
|
export function ProductCard({ product, className }: ProductCardProps) {
|
|
5019
5316
|
const t = useTranslations('common');
|
|
5317
|
+
const tp = useTranslations('productDetail');
|
|
5020
5318
|
const { price, originalPrice, isOnSale } = getProductPriceInfo(product);
|
|
5021
5319
|
const mainImage = product.images?.[0];
|
|
5022
5320
|
const imageUrl = mainImage?.url || null;
|
|
@@ -5061,6 +5359,11 @@ export function ProductCard({ product, className }: ProductCardProps) {
|
|
|
5061
5359
|
</span>
|
|
5062
5360
|
)}
|
|
5063
5361
|
<DiscountBadge discount={product.discount} />
|
|
5362
|
+
{product.isDownloadable && (
|
|
5363
|
+
<span className="bg-primary text-primary-foreground rounded px-2 py-1 text-xs font-bold">
|
|
5364
|
+
{tp('digitalProduct')}
|
|
5365
|
+
</span>
|
|
5366
|
+
)}
|
|
5064
5367
|
</div>
|
|
5065
5368
|
</div>
|
|
5066
5369
|
|
|
@@ -7396,10 +7699,11 @@ export function ProfileSection({ profile, onProfileUpdate, className }: ProfileS
|
|
|
7396
7699
|
`,
|
|
7397
7700
|
"src/components/account/order-history.tsx": `'use client';
|
|
7398
7701
|
|
|
7399
|
-
import { useState } from 'react';
|
|
7702
|
+
import { useState, useEffect } from 'react';
|
|
7400
7703
|
import Image from 'next/image';
|
|
7401
|
-
import type { Order, OrderStatus } from 'brainerce';
|
|
7704
|
+
import type { Order, OrderStatus, OrderDownloadLink } from 'brainerce';
|
|
7402
7705
|
import { formatPrice } from 'brainerce';
|
|
7706
|
+
import { getClient } from '@/lib/brainerce';
|
|
7403
7707
|
import { useTranslations } from '@/lib/translations';
|
|
7404
7708
|
import { cn } from '@/lib/utils';
|
|
7405
7709
|
|
|
@@ -7583,6 +7887,11 @@ function OrderCard({ order }: { order: Order }) {
|
|
|
7583
7887
|
</div>
|
|
7584
7888
|
))}
|
|
7585
7889
|
|
|
7890
|
+
{/* Downloads section */}
|
|
7891
|
+
{order.hasDownloads && (
|
|
7892
|
+
<OrderDownloads orderId={order.id} />
|
|
7893
|
+
)}
|
|
7894
|
+
|
|
7586
7895
|
<OrderFinancialSummary order={order} currency={currency} />
|
|
7587
7896
|
</div>
|
|
7588
7897
|
)}
|
|
@@ -7590,6 +7899,71 @@ function OrderCard({ order }: { order: Order }) {
|
|
|
7590
7899
|
);
|
|
7591
7900
|
}
|
|
7592
7901
|
|
|
7902
|
+
function OrderDownloads({ orderId }: { orderId: string }) {
|
|
7903
|
+
const t = useTranslations('account');
|
|
7904
|
+
const [downloads, setDownloads] = useState<OrderDownloadLink[] | null>(null);
|
|
7905
|
+
const [loading, setLoading] = useState(true);
|
|
7906
|
+
|
|
7907
|
+
useEffect(() => {
|
|
7908
|
+
let cancelled = false;
|
|
7909
|
+
async function fetch() {
|
|
7910
|
+
try {
|
|
7911
|
+
const client = getClient();
|
|
7912
|
+
const links = await client.getOrderDownloads(orderId);
|
|
7913
|
+
if (!cancelled) setDownloads(links);
|
|
7914
|
+
} catch {
|
|
7915
|
+
if (!cancelled) setDownloads([]);
|
|
7916
|
+
} finally {
|
|
7917
|
+
if (!cancelled) setLoading(false);
|
|
7918
|
+
}
|
|
7919
|
+
}
|
|
7920
|
+
fetch();
|
|
7921
|
+
return () => { cancelled = true; };
|
|
7922
|
+
}, [orderId]);
|
|
7923
|
+
|
|
7924
|
+
if (loading) {
|
|
7925
|
+
return (
|
|
7926
|
+
<div className="border-border border-t pt-2">
|
|
7927
|
+
<p className="text-muted-foreground animate-pulse text-xs">{t('downloads')}...</p>
|
|
7928
|
+
</div>
|
|
7929
|
+
);
|
|
7930
|
+
}
|
|
7931
|
+
|
|
7932
|
+
if (!downloads || downloads.length === 0) return null;
|
|
7933
|
+
|
|
7934
|
+
return (
|
|
7935
|
+
<div className="border-border space-y-2 border-t pt-2">
|
|
7936
|
+
<p className="text-foreground text-sm font-medium">{t('downloads')}</p>
|
|
7937
|
+
{downloads.map((link, idx) => (
|
|
7938
|
+
<div key={idx} className="flex items-center gap-3">
|
|
7939
|
+
<div className="min-w-0 flex-1">
|
|
7940
|
+
<p className="text-foreground truncate text-sm">{link.fileName}</p>
|
|
7941
|
+
<p className="text-muted-foreground text-xs">
|
|
7942
|
+
{link.productName}
|
|
7943
|
+
{' \xB7 '}
|
|
7944
|
+
{link.downloadLimit != null
|
|
7945
|
+
? t('downloadsRemaining', { used: link.downloadsUsed, limit: link.downloadLimit })
|
|
7946
|
+
: t('unlimitedDownloads')}
|
|
7947
|
+
{' \xB7 '}
|
|
7948
|
+
{link.expiresAt
|
|
7949
|
+
? t('expiresAt', { date: new Date(link.expiresAt).toLocaleDateString() })
|
|
7950
|
+
: t('noExpiry')}
|
|
7951
|
+
</p>
|
|
7952
|
+
</div>
|
|
7953
|
+
<a
|
|
7954
|
+
href={link.downloadUrl}
|
|
7955
|
+
target="_blank"
|
|
7956
|
+
rel="noopener noreferrer"
|
|
7957
|
+
className="bg-primary text-primary-foreground flex-shrink-0 rounded px-3 py-1 text-xs font-medium hover:opacity-90"
|
|
7958
|
+
>
|
|
7959
|
+
{t('downloadFile')}
|
|
7960
|
+
</a>
|
|
7961
|
+
</div>
|
|
7962
|
+
))}
|
|
7963
|
+
</div>
|
|
7964
|
+
);
|
|
7965
|
+
}
|
|
7966
|
+
|
|
7593
7967
|
function OrderFinancialSummary({ order, currency }: { order: Order; currency: string }) {
|
|
7594
7968
|
const tc = useTranslations('common');
|
|
7595
7969
|
const totalAmount = order.totalAmount || order.total || '0';
|
|
@@ -8471,7 +8845,7 @@ var DETAILED = `# Required Pages \u2014 Detailed Guide
|
|
|
8471
8845
|
- Stock badge
|
|
8472
8846
|
- Discount badge (\`getProductDiscountBadge()\`)
|
|
8473
8847
|
- Add to cart button (disabled when \`!canPurchase\`)
|
|
8474
|
-
- Product recommendations (\`
|
|
8848
|
+
- Product recommendations (from \`product.recommendations\` \u2014 embedded in product response)
|
|
8475
8849
|
- Use \`client.getProductBySlug(slug)\`
|
|
8476
8850
|
|
|
8477
8851
|
## 4. Cart (\`/cart\`)
|