@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/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 cross-sells, upsells, related
720
- const recs: ProductRecommendationsResponse = await client.getProductRecommendations(productId);
721
- // recs.crossSells, recs.upsells, recs.related
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 "You might also like" on product pages, "Recommended for you" in cart.`;
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 CategoryFilter {
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<CategoryFilter[]>([]);
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.map((c) => ({ id: c.id, name: c.name })));
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
- <button
2759
+ <CategoryChip
2562
2760
  key={cat.id}
2563
- onClick={() => updateParam('category', cat.id)}
2564
- className={cn(
2565
- 'rounded-full border px-3 py-1.5 text-sm transition-colors',
2566
- categoryId === cat.id
2567
- ? 'bg-primary text-primary-foreground border-primary'
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 { Product, ProductVariant, ProductImage, ProductMetafield } from 'brainerce';
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<Product | null>(null);
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
- <StockBadge inventory={inventory} lowStockThreshold={5} />
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 (\`getProductRecommendations()\`)
8848
+ - Product recommendations (from \`product.recommendations\` \u2014 embedded in product response)
8475
8849
  - Use \`client.getProductBySlug(slug)\`
8476
8850
 
8477
8851
  ## 4. Cart (\`/cart\`)