@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/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 cross-sells, upsells, related
735
- const recs: ProductRecommendationsResponse = await client.getProductRecommendations(productId);
736
- // recs.crossSells, recs.upsells, recs.related
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 "You might also like" on product pages, "Recommended for you" in cart.`;
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 CategoryFilter {
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<CategoryFilter[]>([]);
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.map((c) => ({ id: c.id, name: c.name })));
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
- <button
2774
+ <CategoryChip
2577
2775
  key={cat.id}
2578
- onClick={() => updateParam('category', cat.id)}
2579
- className={cn(
2580
- 'rounded-full border px-3 py-1.5 text-sm transition-colors',
2581
- categoryId === cat.id
2582
- ? 'bg-primary text-primary-foreground border-primary'
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 { Product, ProductVariant, ProductImage, ProductMetafield } from 'brainerce';
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<Product | null>(null);
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
- <StockBadge inventory={inventory} lowStockThreshold={5} />
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 (\`getProductRecommendations()\`)
8863
+ - Product recommendations (from \`product.recommendations\` \u2014 embedded in product response)
8490
8864
  - Use \`client.getProductBySlug(slug)\`
8491
8865
 
8492
8866
  ## 4. Cart (\`/cart\`)