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