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