@brainerce/mcp-server 1.1.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
@@ -1895,39 +1900,24 @@ var EMBEDDED_TEMPLATES = {
1895
1900
  "src/lib/brainerce.ts.ejs": `import { BrainerceClient } from 'brainerce';
1896
1901
 
1897
1902
  const CONNECTION_ID = process.env.NEXT_PUBLIC_BRAINERCE_CONNECTION_ID || 'vc_YOUR_CONNECTION_ID';
1898
- const API_URL = process.env.NEXT_PUBLIC_BRAINERCE_API_URL || 'https://api.brainerce.com';
1899
1903
 
1900
- // Singleton SDK client
1904
+ // Singleton SDK client \u2014 routes through same-origin BFF proxy for httpOnly cookie auth
1901
1905
  let clientInstance: BrainerceClient | null = null;
1902
1906
 
1903
1907
  export function getClient(): BrainerceClient {
1904
1908
  if (!clientInstance) {
1905
1909
  clientInstance = new BrainerceClient({
1906
1910
  connectionId: CONNECTION_ID,
1907
- baseUrl: API_URL,
1911
+ baseUrl: '/api/store', // same-origin proxy handles auth via httpOnly cookie
1912
+ proxyMode: true, // skip client-side token checks; proxy adds Authorization header
1908
1913
  });
1909
1914
  }
1910
1915
  return clientInstance;
1911
1916
  }
1912
1917
 
1913
- // Auth token helpers
1914
- const TOKEN_KEY = 'brainerce_customer_token';
1918
+ // Cart ID helpers (not a security token \u2014 safe in localStorage)
1915
1919
  const CART_ID_KEY = 'brainerce_cart_id';
1916
1920
 
1917
- export function getStoredToken(): string | null {
1918
- if (typeof window === 'undefined') return null;
1919
- return localStorage.getItem(TOKEN_KEY);
1920
- }
1921
-
1922
- export function setStoredToken(token: string | null): void {
1923
- if (typeof window === 'undefined') return;
1924
- if (token) {
1925
- localStorage.setItem(TOKEN_KEY, token);
1926
- } else {
1927
- localStorage.removeItem(TOKEN_KEY);
1928
- }
1929
- }
1930
-
1931
1921
  export function getStoredCartId(): string | null {
1932
1922
  if (typeof window === 'undefined') return null;
1933
1923
  return localStorage.getItem(CART_ID_KEY);
@@ -1942,14 +1932,158 @@ export function setStoredCartId(cartId: string | null): void {
1942
1932
  }
1943
1933
  }
1944
1934
 
1945
- // Initialize client with stored auth
1935
+ // Initialize client (no token hydration \u2014 auth handled by httpOnly cookie)
1946
1936
  export function initClient(): BrainerceClient {
1947
- const client = getClient();
1948
- const token = getStoredToken();
1949
- if (token) {
1950
- client.setCustomerToken(token);
1937
+ return getClient();
1938
+ }
1939
+ `,
1940
+ "src/lib/auth.ts": `/**
1941
+ * Client-side auth helpers that call the BFF proxy API routes.
1942
+ * All mutating requests include the CSRF header.
1943
+ * The token is managed server-side via httpOnly cookies \u2014 never exposed to JS.
1944
+ */
1945
+
1946
+ const CONNECTION_ID = process.env.NEXT_PUBLIC_BRAINERCE_CONNECTION_ID || '';
1947
+
1948
+ const CSRF_HEADERS: Record<string, string> = {
1949
+ 'Content-Type': 'application/json',
1950
+ 'X-Requested-With': 'brainerce',
1951
+ };
1952
+
1953
+ interface LoginResult {
1954
+ customer: {
1955
+ id: string;
1956
+ email: string;
1957
+ firstName?: string;
1958
+ lastName?: string;
1959
+ emailVerified: boolean;
1960
+ };
1961
+ expiresAt: string;
1962
+ requiresVerification?: boolean;
1963
+ }
1964
+
1965
+ interface RegisterResult {
1966
+ customer: {
1967
+ id: string;
1968
+ email: string;
1969
+ firstName?: string;
1970
+ lastName?: string;
1971
+ emailVerified: boolean;
1972
+ };
1973
+ expiresAt: string;
1974
+ requiresVerification?: boolean;
1975
+ }
1976
+
1977
+ interface AuthStatus {
1978
+ isLoggedIn: boolean;
1979
+ customer?: {
1980
+ id: string;
1981
+ email: string;
1982
+ firstName?: string;
1983
+ lastName?: string;
1984
+ phone?: string;
1985
+ emailVerified: boolean;
1986
+ };
1987
+ error?: string;
1988
+ }
1989
+
1990
+ interface VerifyEmailResult {
1991
+ verified: boolean;
1992
+ message?: string;
1993
+ }
1994
+
1995
+ async function handleResponse<T>(response: Response): Promise<T> {
1996
+ const data = await response.json();
1997
+ if (!response.ok) {
1998
+ throw new Error(data.message || data.error || \`Request failed (\${response.status})\`);
1951
1999
  }
1952
- return client;
2000
+ return data as T;
2001
+ }
2002
+
2003
+ /**
2004
+ * Login via BFF proxy. The proxy sets the httpOnly cookie on success.
2005
+ */
2006
+ export async function proxyLogin(email: string, password: string): Promise<LoginResult> {
2007
+ const response = await fetch(\`/api/store/api/vc/\${CONNECTION_ID}/customers/login\`, {
2008
+ method: 'POST',
2009
+ headers: CSRF_HEADERS,
2010
+ body: JSON.stringify({ email, password }),
2011
+ });
2012
+ return handleResponse<LoginResult>(response);
2013
+ }
2014
+
2015
+ /**
2016
+ * Register via BFF proxy. The proxy sets the httpOnly cookie on success.
2017
+ */
2018
+ export async function proxyRegister(data: {
2019
+ firstName: string;
2020
+ lastName: string;
2021
+ email: string;
2022
+ password: string;
2023
+ }): Promise<RegisterResult> {
2024
+ const response = await fetch(\`/api/store/api/vc/\${CONNECTION_ID}/customers/register\`, {
2025
+ method: 'POST',
2026
+ headers: CSRF_HEADERS,
2027
+ body: JSON.stringify(data),
2028
+ });
2029
+ return handleResponse<RegisterResult>(response);
2030
+ }
2031
+
2032
+ /**
2033
+ * Check auth status. Reads httpOnly cookie server-side and validates with backend.
2034
+ */
2035
+ export async function checkAuthStatus(): Promise<AuthStatus> {
2036
+ const response = await fetch('/api/auth/me');
2037
+ return response.json();
2038
+ }
2039
+
2040
+ /**
2041
+ * Logout. Clears httpOnly auth cookies server-side.
2042
+ */
2043
+ export async function proxyLogout(): Promise<void> {
2044
+ await fetch('/api/auth/logout', {
2045
+ method: 'POST',
2046
+ headers: { 'X-Requested-With': 'brainerce' },
2047
+ });
2048
+ }
2049
+
2050
+ /**
2051
+ * Verify email via BFF proxy. The auth token is in the httpOnly cookie (set during login/register).
2052
+ * The proxy adds the Authorization header automatically.
2053
+ */
2054
+ export async function proxyVerifyEmail(code: string): Promise<VerifyEmailResult> {
2055
+ const response = await fetch(\`/api/store/api/vc/\${CONNECTION_ID}/customers/verify-email\`, {
2056
+ method: 'POST',
2057
+ headers: CSRF_HEADERS,
2058
+ body: JSON.stringify({ code }),
2059
+ });
2060
+ return handleResponse<VerifyEmailResult>(response);
2061
+ }
2062
+
2063
+ /**
2064
+ * Resend verification email via BFF proxy.
2065
+ * Uses the auth token from the httpOnly cookie.
2066
+ */
2067
+ export async function proxyResendVerification(): Promise<{ message: string }> {
2068
+ const response = await fetch(\`/api/store/api/vc/\${CONNECTION_ID}/customers/resend-verification\`, {
2069
+ method: 'POST',
2070
+ headers: CSRF_HEADERS,
2071
+ });
2072
+ return handleResponse<{ message: string }>(response);
2073
+ }
2074
+
2075
+ /**
2076
+ * Reset password via BFF proxy.
2077
+ * The reset token is in an httpOnly cookie (set by /api/auth/reset-callback when the user
2078
+ * clicked the email link). The proxy reads it server-side \u2014 the token never reaches client JS.
2079
+ */
2080
+ export async function proxyResetPassword(newPassword: string): Promise<{ message: string }> {
2081
+ const response = await fetch('/api/auth/reset-password', {
2082
+ method: 'POST',
2083
+ headers: CSRF_HEADERS,
2084
+ body: JSON.stringify({ newPassword }),
2085
+ });
2086
+ return handleResponse<{ message: string }>(response);
1953
2087
  }
1954
2088
  `,
1955
2089
  "src/lib/utils.ts": `import { clsx, type ClassValue } from 'clsx';
@@ -1962,9 +2096,10 @@ export function cn(...inputs: ClassValue[]) {
1962
2096
  "src/providers/store-provider.tsx.ejs": `'use client';
1963
2097
 
1964
2098
  import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
1965
- import type { StoreInfo, Cart } from 'brainerce';
2099
+ import type { StoreInfo, Cart, CustomerProfile } from 'brainerce';
1966
2100
  import { getCartTotals } from 'brainerce';
1967
- import { getClient, initClient, getStoredToken, setStoredToken, setStoredCartId } from '@/lib/brainerce';
2101
+ import { getClient, initClient, setStoredCartId } from '@/lib/brainerce';
2102
+ import { checkAuthStatus, proxyLogout } from '@/lib/auth';
1968
2103
 
1969
2104
  // ---- Store Info Context ----
1970
2105
  interface StoreInfoContextValue {
@@ -1985,17 +2120,17 @@ export function useStoreInfo() {
1985
2120
  interface AuthContextValue {
1986
2121
  isLoggedIn: boolean;
1987
2122
  authLoading: boolean;
1988
- token: string | null;
1989
- login: (token: string) => void;
1990
- logout: () => void;
2123
+ customer: CustomerProfile | null;
2124
+ login: () => Promise<void>;
2125
+ logout: () => Promise<void>;
1991
2126
  }
1992
2127
 
1993
2128
  const AuthContext = createContext<AuthContextValue>({
1994
2129
  isLoggedIn: false,
1995
2130
  authLoading: true,
1996
- token: null,
1997
- login: () => {},
1998
- logout: () => {},
2131
+ customer: null,
2132
+ login: async () => {},
2133
+ logout: async () => {},
1999
2134
  });
2000
2135
 
2001
2136
  export function useAuth() {
@@ -2027,26 +2162,46 @@ export function useCart() {
2027
2162
  export function StoreProvider({ children }: { children: React.ReactNode }) {
2028
2163
  const [storeInfo, setStoreInfo] = useState<StoreInfo | null>(null);
2029
2164
  const [storeLoading, setStoreLoading] = useState(true);
2030
- const [token, setToken] = useState<string | null>(null);
2165
+ const [isLoggedIn, setIsLoggedIn] = useState(false);
2166
+ const [customer, setCustomer] = useState<CustomerProfile | null>(null);
2031
2167
  const [authLoading, setAuthLoading] = useState(true);
2032
2168
  const [cart, setCart] = useState<Cart | null>(null);
2033
2169
  const [cartLoading, setCartLoading] = useState(true);
2034
2170
 
2035
- // Initialize client and auth
2171
+ // Check auth status via httpOnly cookie (server-side validation)
2172
+ const refreshAuth = useCallback(async () => {
2173
+ try {
2174
+ const status = await checkAuthStatus();
2175
+ setIsLoggedIn(status.isLoggedIn);
2176
+ setCustomer(status.isLoggedIn ? (status.customer as CustomerProfile) : null);
2177
+ } catch {
2178
+ setIsLoggedIn(false);
2179
+ setCustomer(null);
2180
+ } finally {
2181
+ setAuthLoading(false);
2182
+ }
2183
+ }, []);
2184
+
2185
+ // Initialize client, check auth, and fetch store info
2036
2186
  useEffect(() => {
2037
2187
  const client = initClient();
2038
- const stored = getStoredToken();
2039
- if (stored) {
2040
- setToken(stored);
2188
+
2189
+ // Optimistic check: if brainerce_logged_in cookie exists, assume logged in
2190
+ // while we validate the actual token server-side
2191
+ if (typeof document !== 'undefined' && document.cookie.includes('brainerce_logged_in=1')) {
2192
+ setIsLoggedIn(true);
2041
2193
  }
2042
- setAuthLoading(false);
2043
2194
 
2195
+ // Validate auth token server-side
2196
+ refreshAuth();
2197
+
2198
+ // Fetch store info (public, no auth needed)
2044
2199
  client
2045
2200
  .getStoreInfo()
2046
2201
  .then(setStoreInfo)
2047
2202
  .catch(console.error)
2048
2203
  .finally(() => setStoreLoading(false));
2049
- }, []);
2204
+ }, [refreshAuth]);
2050
2205
 
2051
2206
  // Cart management
2052
2207
  const refreshCart = useCallback(async () => {
@@ -2069,24 +2224,26 @@ export function StoreProvider({ children }: { children: React.ReactNode }) {
2069
2224
 
2070
2225
  useEffect(() => {
2071
2226
  refreshCart();
2072
- }, [refreshCart, token]);
2227
+ }, [refreshCart, isLoggedIn]);
2073
2228
 
2074
- const login = useCallback((newToken: string) => {
2075
- const client = getClient();
2076
- client.setCustomerToken(newToken);
2077
- setStoredToken(newToken);
2078
- setToken(newToken);
2229
+ // Called after successful login (cookie already set by proxy)
2230
+ const login = useCallback(async () => {
2231
+ // Refresh auth state from server (reads httpOnly cookie)
2232
+ await refreshAuth();
2079
2233
 
2080
2234
  // Merge guest session cart into customer cart
2235
+ const client = getClient();
2081
2236
  client.syncCartOnLogin().catch(console.error);
2082
- }, []);
2237
+ }, [refreshAuth]);
2238
+
2239
+ const logout = useCallback(async () => {
2240
+ // Clear httpOnly cookie server-side
2241
+ await proxyLogout();
2083
2242
 
2084
- const logout = useCallback(() => {
2085
2243
  const client = getClient();
2086
- client.clearCustomerToken();
2087
2244
  client.onLogout();
2088
- setStoredToken(null);
2089
- setToken(null);
2245
+ setIsLoggedIn(false);
2246
+ setCustomer(null);
2090
2247
  setCart(null);
2091
2248
  refreshCart();
2092
2249
  }, [refreshCart]);
@@ -2099,7 +2256,7 @@ export function StoreProvider({ children }: { children: React.ReactNode }) {
2099
2256
 
2100
2257
  return (
2101
2258
  <StoreInfoContext.Provider value={{ storeInfo, loading: storeLoading }}>
2102
- <AuthContext.Provider value={{ isLoggedIn: !!token, authLoading, token, login, logout }}>
2259
+ <AuthContext.Provider value={{ isLoggedIn, authLoading, customer, login, logout }}>
2103
2260
  <CartContext.Provider
2104
2261
  value={{ cart, cartLoading, refreshCart, itemCount, totals }}
2105
2262
  >
@@ -2248,7 +2405,7 @@ export default function RootLayout({
2248
2405
  `,
2249
2406
  "src/app/products/page.tsx": `'use client';
2250
2407
 
2251
- import { Suspense, useEffect, useState, useCallback } from 'react';
2408
+ import { Suspense, useEffect, useState, useCallback, useRef } from 'react';
2252
2409
  import { useSearchParams, useRouter } from 'next/navigation';
2253
2410
  import type { Product } from 'brainerce';
2254
2411
  import type { ProductQueryParams } from 'brainerce';
@@ -2274,9 +2431,198 @@ const sortOptions: SortOption[] = [
2274
2431
  { labelKey: 'sortPriceHigh', sortBy: 'price', sortOrder: 'desc' },
2275
2432
  ];
2276
2433
 
2277
- interface CategoryFilter {
2434
+ interface CategoryNode {
2278
2435
  id: string;
2279
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
+ );
2280
2626
  }
2281
2627
 
2282
2628
  function ProductsContent() {
@@ -2295,18 +2641,18 @@ function ProductsContent() {
2295
2641
  const [page, setPage] = useState(1);
2296
2642
  const [totalPages, setTotalPages] = useState(1);
2297
2643
  const [total, setTotal] = useState(0);
2298
- const [categories, setCategories] = useState<CategoryFilter[]>([]);
2644
+ const [categories, setCategories] = useState<CategoryNode[]>([]);
2299
2645
 
2300
2646
  const sortIndex = parseInt(sortParam, 10) || 0;
2301
2647
  const currentSort = sortOptions[sortIndex] || sortOptions[0];
2302
2648
 
2303
- // Load categories
2649
+ // Load categories (keep tree structure)
2304
2650
  useEffect(() => {
2305
2651
  async function loadCategories() {
2306
2652
  try {
2307
2653
  const client = getClient();
2308
2654
  const result = await client.getCategories();
2309
- setCategories(result.categories.map((c) => ({ id: c.id, name: c.name })));
2655
+ setCategories(result.categories as CategoryNode[]);
2310
2656
  } catch {
2311
2657
  // Categories endpoint may not be available in all modes
2312
2658
  }
@@ -2375,6 +2721,10 @@ function ProductsContent() {
2375
2721
  router.push(\`/products?\${params.toString()}\`);
2376
2722
  }
2377
2723
 
2724
+ function handleCategorySelect(id: string) {
2725
+ updateParam('category', id);
2726
+ }
2727
+
2378
2728
  return (
2379
2729
  <div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
2380
2730
  {/* Page Header */}
@@ -2406,18 +2756,13 @@ function ProductsContent() {
2406
2756
  {tc('all')}
2407
2757
  </button>
2408
2758
  {categories.map((cat) => (
2409
- <button
2759
+ <CategoryChip
2410
2760
  key={cat.id}
2411
- onClick={() => updateParam('category', cat.id)}
2412
- className={cn(
2413
- 'rounded-full border px-3 py-1.5 text-sm transition-colors',
2414
- categoryId === cat.id
2415
- ? 'bg-primary text-primary-foreground border-primary'
2416
- : 'border-border text-muted-foreground hover:border-primary hover:text-foreground'
2417
- )}
2418
- >
2419
- {cat.name}
2420
- </button>
2761
+ category={cat}
2762
+ selectedId={categoryId}
2763
+ onSelect={handleCategorySelect}
2764
+ tc={tc as (key: string) => string}
2765
+ />
2421
2766
  ))}
2422
2767
  </div>
2423
2768
  )}
@@ -2499,7 +2844,18 @@ import { useEffect, useState, useMemo } from 'react';
2499
2844
  import { useParams } from 'next/navigation';
2500
2845
  import Image from 'next/image';
2501
2846
  import Link from 'next/link';
2502
- 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
+ };
2503
2859
  import { getProductPriceInfo, getDescriptionContent } from 'brainerce';
2504
2860
  import { getClient } from '@/lib/brainerce';
2505
2861
  import { useCart } from '@/providers/store-provider';
@@ -2507,6 +2863,7 @@ import { PriceDisplay } from '@/components/shared/price-display';
2507
2863
  import { LoadingSpinner } from '@/components/shared/loading-spinner';
2508
2864
  import { VariantSelector } from '@/components/products/variant-selector';
2509
2865
  import { StockBadge } from '@/components/products/stock-badge';
2866
+ import { RecommendationSection } from '@/components/products/recommendation-section';
2510
2867
  import { useTranslations } from '@/lib/translations';
2511
2868
  import { cn } from '@/lib/utils';
2512
2869
 
@@ -2600,7 +2957,7 @@ export default function ProductDetailPage() {
2600
2957
  const { refreshCart } = useCart();
2601
2958
  const t = useTranslations('productDetail');
2602
2959
  const tc = useTranslations('common');
2603
- const [product, setProduct] = useState<Product | null>(null);
2960
+ const [product, setProduct] = useState<ProductWithRecommendations | null>(null);
2604
2961
  const [loading, setLoading] = useState(true);
2605
2962
  const [error, setError] = useState<string | null>(null);
2606
2963
  const [selectedVariant, setSelectedVariant] = useState<ProductVariant | null>(null);
@@ -2609,6 +2966,8 @@ export default function ProductDetailPage() {
2609
2966
  const [addingToCart, setAddingToCart] = useState(false);
2610
2967
  const [addedMessage, setAddedMessage] = useState(false);
2611
2968
 
2969
+ const recommendations = product?.recommendations ?? null;
2970
+
2612
2971
  // Load product
2613
2972
  useEffect(() => {
2614
2973
  async function load() {
@@ -2617,7 +2976,7 @@ export default function ProductDetailPage() {
2617
2976
  setError(null);
2618
2977
  const client = getClient();
2619
2978
  const p = await client.getProductBySlug(slug);
2620
- setProduct(p);
2979
+ setProduct(p as ProductWithRecommendations);
2621
2980
 
2622
2981
  // Auto-select first variant
2623
2982
  if (p.variants && p.variants.length > 0) {
@@ -2823,8 +3182,43 @@ export default function ProductDetailPage() {
2823
3182
  size="lg"
2824
3183
  />
2825
3184
 
2826
- {/* Stock */}
2827
- <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
+ )}
2828
3222
 
2829
3223
  {/* Variant Selector */}
2830
3224
  {product.type === 'VARIABLE' && product.variants && product.variants.length > 0 && (
@@ -2888,6 +3282,13 @@ export default function ProductDetailPage() {
2888
3282
  </button>
2889
3283
  </div>
2890
3284
 
3285
+ {/* Download after purchase note */}
3286
+ {product.isDownloadable && (
3287
+ <p className="text-muted-foreground text-sm">
3288
+ {t('downloadAfterPurchase')}
3289
+ </p>
3290
+ )}
3291
+
2891
3292
  {/* Description */}
2892
3293
  {description && (
2893
3294
  <div className="border-border border-t pt-4">
@@ -2925,19 +3326,41 @@ export default function ProductDetailPage() {
2925
3326
  )}
2926
3327
  </div>
2927
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
+ )}
2928
3347
  </div>
2929
3348
  );
2930
3349
  }
2931
3350
  `,
2932
3351
  "src/app/cart/page.tsx": `'use client';
2933
3352
 
3353
+ import { useEffect, useState } from 'react';
2934
3354
  import Link from 'next/link';
3355
+ import type { CartRecommendationsResponse } from 'brainerce';
3356
+ import { getClient } from '@/lib/brainerce';
2935
3357
  import { useCart } from '@/providers/store-provider';
2936
3358
  import { CartItem } from '@/components/cart/cart-item';
2937
3359
  import { CartSummary } from '@/components/cart/cart-summary';
2938
3360
  import { CouponInput } from '@/components/cart/coupon-input';
2939
3361
  import { CartNudges } from '@/components/cart/cart-nudges';
2940
3362
  import { ReservationCountdown } from '@/components/cart/reservation-countdown';
3363
+ import { CartRecommendationSection } from '@/components/products/recommendation-section';
2941
3364
  import { LoadingSpinner } from '@/components/shared/loading-spinner';
2942
3365
  import { useTranslations } from '@/lib/translations';
2943
3366
 
@@ -2945,6 +3368,17 @@ export default function CartPage() {
2945
3368
  const { cart, cartLoading, refreshCart, itemCount } = useCart();
2946
3369
  const t = useTranslations('cart');
2947
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]);
2948
3382
 
2949
3383
  if (cartLoading) {
2950
3384
  return (
@@ -3036,6 +3470,15 @@ export default function CartPage() {
3036
3470
  </div>
3037
3471
  </div>
3038
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
+ )}
3039
3482
  </div>
3040
3483
  );
3041
3484
  }
@@ -3919,8 +4362,8 @@ export default function OrderConfirmationPage() {
3919
4362
  import { useState } from 'react';
3920
4363
  import { useRouter } from 'next/navigation';
3921
4364
  import Link from 'next/link';
3922
- import { getClient } from '@/lib/brainerce';
3923
4365
  import { useAuth } from '@/providers/store-provider';
4366
+ import { proxyLogin } from '@/lib/auth';
3924
4367
  import { LoginForm } from '@/components/auth/login-form';
3925
4368
  import { OAuthButtons } from '@/components/auth/oauth-buttons';
3926
4369
  import { useTranslations } from '@/lib/translations';
@@ -3934,15 +4377,16 @@ export default function LoginPage() {
3934
4377
  async function handleLogin(email: string, password: string) {
3935
4378
  try {
3936
4379
  setError(null);
3937
- const client = getClient();
3938
- const result = await client.loginCustomer(email, password);
4380
+ const result = await proxyLogin(email, password);
3939
4381
 
3940
4382
  if (result.requiresVerification) {
3941
- router.push(\`/verify-email?token=\${encodeURIComponent(result.token)}\`);
4383
+ // Verification token is NOT the auth JWT \u2014 safe to pass in URL
4384
+ router.push('/verify-email');
3942
4385
  return;
3943
4386
  }
3944
4387
 
3945
- auth.login(result.token);
4388
+ // Cookie was set by the proxy; refresh auth state
4389
+ await auth.login();
3946
4390
  router.push('/');
3947
4391
  } catch (err) {
3948
4392
  const message = err instanceof Error ? err.message : 'Login failed. Please try again.';
@@ -3978,8 +4422,8 @@ export default function LoginPage() {
3978
4422
  import { useState } from 'react';
3979
4423
  import { useRouter } from 'next/navigation';
3980
4424
  import Link from 'next/link';
3981
- import { getClient } from '@/lib/brainerce';
3982
4425
  import { useAuth } from '@/providers/store-provider';
4426
+ import { proxyRegister } from '@/lib/auth';
3983
4427
  import { RegisterForm } from '@/components/auth/register-form';
3984
4428
  import { OAuthButtons } from '@/components/auth/oauth-buttons';
3985
4429
  import { useTranslations } from '@/lib/translations';
@@ -3998,20 +4442,16 @@ export default function RegisterPage() {
3998
4442
  }) {
3999
4443
  try {
4000
4444
  setError(null);
4001
- const client = getClient();
4002
- const result = await client.registerCustomer({
4003
- firstName: data.firstName,
4004
- lastName: data.lastName,
4005
- email: data.email,
4006
- password: data.password,
4007
- });
4445
+ const result = await proxyRegister(data);
4008
4446
 
4009
4447
  if (result.requiresVerification) {
4010
- router.push(\`/verify-email?token=\${encodeURIComponent(result.token)}\`);
4448
+ // Cookie already set by proxy; verify-email uses it for auth
4449
+ router.push('/verify-email');
4011
4450
  return;
4012
4451
  }
4013
4452
 
4014
- auth.login(result.token);
4453
+ // Cookie was set by the proxy; refresh auth state
4454
+ await auth.login();
4015
4455
  router.push('/');
4016
4456
  } catch (err) {
4017
4457
  const message = err instanceof Error ? err.message : 'Registration failed. Please try again.';
@@ -4045,10 +4485,10 @@ export default function RegisterPage() {
4045
4485
  "src/app/verify-email/page.tsx": `'use client';
4046
4486
 
4047
4487
  import { Suspense, useState, useRef, useEffect, useCallback } from 'react';
4048
- import { useRouter, useSearchParams } from 'next/navigation';
4488
+ import { useRouter } from 'next/navigation';
4049
4489
  import Link from 'next/link';
4050
- import { getClient } from '@/lib/brainerce';
4051
4490
  import { useAuth } from '@/providers/store-provider';
4491
+ import { proxyVerifyEmail, proxyResendVerification } from '@/lib/auth';
4052
4492
  import { LoadingSpinner } from '@/components/shared/loading-spinner';
4053
4493
  import { useTranslations } from '@/lib/translations';
4054
4494
 
@@ -4057,10 +4497,8 @@ const RESEND_COOLDOWN_SECONDS = 60;
4057
4497
 
4058
4498
  function VerifyEmailContent() {
4059
4499
  const router = useRouter();
4060
- const searchParams = useSearchParams();
4061
4500
  const auth = useAuth();
4062
4501
 
4063
- const token = searchParams.get('token');
4064
4502
  const t = useTranslations('auth');
4065
4503
 
4066
4504
  const [digits, setDigits] = useState<string[]>(Array(CODE_LENGTH).fill(''));
@@ -4088,18 +4526,17 @@ function VerifyEmailContent() {
4088
4526
 
4089
4527
  const handleSubmit = useCallback(
4090
4528
  async (code: string) => {
4091
- if (!token || code.length !== CODE_LENGTH || loading) return;
4529
+ if (code.length !== CODE_LENGTH || loading) return;
4092
4530
 
4093
4531
  try {
4094
4532
  setLoading(true);
4095
4533
  setError(null);
4096
- const client = getClient();
4097
- const result = await client.verifyEmail(code, token);
4534
+ // Auth token is in httpOnly cookie \u2014 proxy adds Authorization header
4535
+ const result = await proxyVerifyEmail(code);
4098
4536
 
4099
4537
  if (result.verified) {
4100
- // token field exists on newer SDK versions
4101
- const authToken = (result as unknown as { token?: string }).token || token;
4102
- auth.login(authToken);
4538
+ // Refresh auth state (cookie already set)
4539
+ await auth.login();
4103
4540
  setSuccess('Email verified successfully! Redirecting...');
4104
4541
  setTimeout(() => router.push('/'), 1500);
4105
4542
  } else {
@@ -4113,7 +4550,7 @@ function VerifyEmailContent() {
4113
4550
  setLoading(false);
4114
4551
  }
4115
4552
  },
4116
- [token, loading, auth, router]
4553
+ [loading, auth, router]
4117
4554
  );
4118
4555
 
4119
4556
  function handleDigitChange(index: number, value: string) {
@@ -4167,13 +4604,12 @@ function VerifyEmailContent() {
4167
4604
  }
4168
4605
 
4169
4606
  async function handleResend() {
4170
- if (!token || resending || cooldown > 0) return;
4607
+ if (resending || cooldown > 0) return;
4171
4608
 
4172
4609
  try {
4173
4610
  setResending(true);
4174
4611
  setError(null);
4175
- const client = getClient();
4176
- await client.resendVerificationEmail(token);
4612
+ await proxyResendVerification();
4177
4613
  setSuccess('Verification code sent! Check your email.');
4178
4614
  setCooldown(RESEND_COOLDOWN_SECONDS);
4179
4615
  // Clear digits for fresh entry
@@ -4193,37 +4629,6 @@ function VerifyEmailContent() {
4193
4629
  handleSubmit(code);
4194
4630
  }
4195
4631
 
4196
- // No token provided
4197
- if (!token) {
4198
- return (
4199
- <div className="flex min-h-[60vh] items-center justify-center px-4 py-12">
4200
- <div className="w-full max-w-md space-y-4 text-center">
4201
- <svg
4202
- className="text-muted-foreground mx-auto h-12 w-12"
4203
- fill="none"
4204
- viewBox="0 0 24 24"
4205
- stroke="currentColor"
4206
- >
4207
- <path
4208
- strokeLinecap="round"
4209
- strokeLinejoin="round"
4210
- strokeWidth={1.5}
4211
- d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
4212
- />
4213
- </svg>
4214
- <h1 className="text-foreground text-2xl font-bold">{t('verificationInvalid')}</h1>
4215
- <p className="text-muted-foreground text-sm">{t('verificationInvalidDesc')}</p>
4216
- <Link
4217
- href="/register"
4218
- className="bg-primary text-primary-foreground inline-flex items-center rounded px-6 py-3 font-medium transition-opacity hover:opacity-90"
4219
- >
4220
- {t('goToRegister')}
4221
- </Link>
4222
- </div>
4223
- </div>
4224
- );
4225
- }
4226
-
4227
4632
  return (
4228
4633
  <div className="flex min-h-[60vh] items-center justify-center px-4 py-12">
4229
4634
  <div className="w-full max-w-md space-y-6">
@@ -4353,8 +4758,8 @@ function OAuthCallbackContent() {
4353
4758
  const t = useTranslations('auth');
4354
4759
 
4355
4760
  const oauthSuccess = searchParams.get('oauth_success');
4356
- const token = searchParams.get('token');
4357
4761
  const oauthError = searchParams.get('oauth_error');
4762
+ // Token is no longer in URL \u2014 it was set as httpOnly cookie by /api/auth/oauth-callback
4358
4763
 
4359
4764
  useEffect(() => {
4360
4765
  // Prevent double-processing in React StrictMode
@@ -4366,13 +4771,15 @@ function OAuthCallbackContent() {
4366
4771
  return;
4367
4772
  }
4368
4773
 
4369
- if (oauthSuccess === 'true' && token) {
4370
- auth.login(token);
4371
- router.push('/');
4774
+ if (oauthSuccess === 'true') {
4775
+ // Cookie was already set by the API route; refresh auth state
4776
+ auth.login().then(() => {
4777
+ router.push('/');
4778
+ });
4372
4779
  } else {
4373
4780
  setError(t('authFailedDesc'));
4374
4781
  }
4375
- }, [oauthSuccess, token, oauthError, auth, router]);
4782
+ }, [oauthSuccess, oauthError, auth, router, t]);
4376
4783
 
4377
4784
  if (error) {
4378
4785
  return (
@@ -4539,6 +4946,354 @@ export default function AccountPage() {
4539
4946
  </div>
4540
4947
  );
4541
4948
  }
4949
+ `,
4950
+ "src/app/api/store/[...path]/route.ts": `import { NextRequest, NextResponse } from 'next/server';
4951
+ import { cookies } from 'next/headers';
4952
+
4953
+ const BACKEND_URL = (process.env.BRAINERCE_API_URL || 'https://api.brainerce.com').replace(
4954
+ /\\/$/,
4955
+ ''
4956
+ );
4957
+
4958
+ const TOKEN_COOKIE = 'brainerce_customer_token';
4959
+ const LOGGED_IN_COOKIE = 'brainerce_logged_in';
4960
+ const CSRF_HEADER = 'x-requested-with';
4961
+ const CSRF_VALUE = 'brainerce';
4962
+
4963
+ const COOKIE_MAX_AGE = 7 * 24 * 60 * 60; // 7 days
4964
+
4965
+ /** Auth endpoints whose responses contain tokens to intercept */
4966
+ const AUTH_ENDPOINTS = ['customers/login', 'customers/register', 'customers/verify-email'];
4967
+
4968
+ function isAuthEndpoint(path: string): boolean {
4969
+ return AUTH_ENDPOINTS.some((ep) => path.endsWith(ep));
4970
+ }
4971
+
4972
+ function isSecure(): boolean {
4973
+ return process.env.NODE_ENV === 'production';
4974
+ }
4975
+
4976
+ function setAuthCookies(response: NextResponse, token: string): void {
4977
+ response.cookies.set(TOKEN_COOKIE, token, {
4978
+ httpOnly: true,
4979
+ secure: isSecure(),
4980
+ sameSite: 'lax',
4981
+ path: '/',
4982
+ maxAge: COOKIE_MAX_AGE,
4983
+ });
4984
+ response.cookies.set(LOGGED_IN_COOKIE, '1', {
4985
+ httpOnly: false,
4986
+ secure: isSecure(),
4987
+ sameSite: 'lax',
4988
+ path: '/',
4989
+ maxAge: COOKIE_MAX_AGE,
4990
+ });
4991
+ }
4992
+
4993
+ function clearAuthCookies(response: NextResponse): void {
4994
+ response.cookies.delete(TOKEN_COOKIE);
4995
+ response.cookies.delete(LOGGED_IN_COOKIE);
4996
+ }
4997
+
4998
+ async function proxyRequest(
4999
+ request: NextRequest,
5000
+ params: { path: string[] }
5001
+ ): Promise<NextResponse> {
5002
+ const method = request.method;
5003
+
5004
+ // CSRF protection for mutating requests
5005
+ if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
5006
+ const csrfHeader = request.headers.get(CSRF_HEADER);
5007
+ if (csrfHeader !== CSRF_VALUE) {
5008
+ return NextResponse.json({ error: 'CSRF validation failed' }, { status: 403 });
5009
+ }
5010
+ }
5011
+
5012
+ // Build backend URL from path segments
5013
+ const pathSegments = params.path.join('/');
5014
+ const backendUrl = new URL(\`\${BACKEND_URL}/\${pathSegments}\`);
5015
+
5016
+ // Forward query parameters
5017
+ request.nextUrl.searchParams.forEach((value, key) => {
5018
+ backendUrl.searchParams.set(key, value);
5019
+ });
5020
+
5021
+ // Build headers for backend request
5022
+ const headers: Record<string, string> = {
5023
+ 'Content-Type': 'application/json',
5024
+ };
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
+
5032
+ // Forward SDK version header if present
5033
+ const sdkVersion = request.headers.get('x-sdk-version');
5034
+ if (sdkVersion) {
5035
+ headers['X-SDK-Version'] = sdkVersion;
5036
+ }
5037
+
5038
+ // Add auth token from httpOnly cookie
5039
+ const cookieStore = await cookies();
5040
+ const tokenCookie = cookieStore.get(TOKEN_COOKIE);
5041
+ if (tokenCookie?.value) {
5042
+ headers['Authorization'] = \`Bearer \${tokenCookie.value}\`;
5043
+ }
5044
+
5045
+ // Forward request body for non-GET requests
5046
+ let body: string | undefined;
5047
+ if (method !== 'GET' && method !== 'HEAD') {
5048
+ try {
5049
+ body = await request.text();
5050
+ } catch {
5051
+ // No body
5052
+ }
5053
+ }
5054
+
5055
+ // Proxy the request to backend
5056
+ let backendResponse: Response;
5057
+ try {
5058
+ backendResponse = await fetch(backendUrl.toString(), {
5059
+ method,
5060
+ headers,
5061
+ body,
5062
+ });
5063
+ } catch (error) {
5064
+ return NextResponse.json({ error: 'Backend service unavailable' }, { status: 502 });
5065
+ }
5066
+
5067
+ // Read response body
5068
+ const responseText = await backendResponse.text();
5069
+
5070
+ // For auth endpoints: intercept token, set cookie, strip token from response
5071
+ if (backendResponse.ok && method === 'POST' && isAuthEndpoint(pathSegments)) {
5072
+ try {
5073
+ const data = JSON.parse(responseText);
5074
+ if (data.token) {
5075
+ const token = data.token;
5076
+
5077
+ // Strip token from client response
5078
+ const { token: _stripped, ...safeData } = data;
5079
+
5080
+ const response = NextResponse.json(safeData, {
5081
+ status: backendResponse.status,
5082
+ });
5083
+ setAuthCookies(response, token);
5084
+ return response;
5085
+ }
5086
+ } catch {
5087
+ // Not JSON or no token field \u2014 pass through
5088
+ }
5089
+ }
5090
+
5091
+ // Handle 401 responses: clear auth cookies
5092
+ if (backendResponse.status === 401 && tokenCookie?.value) {
5093
+ const response = new NextResponse(responseText, {
5094
+ status: backendResponse.status,
5095
+ headers: {
5096
+ 'Content-Type': backendResponse.headers.get('Content-Type') || 'application/json',
5097
+ },
5098
+ });
5099
+ clearAuthCookies(response);
5100
+ return response;
5101
+ }
5102
+
5103
+ // Pass through response as-is
5104
+ return new NextResponse(responseText, {
5105
+ status: backendResponse.status,
5106
+ headers: {
5107
+ 'Content-Type': backendResponse.headers.get('Content-Type') || 'application/json',
5108
+ },
5109
+ });
5110
+ }
5111
+
5112
+ export async function GET(
5113
+ request: NextRequest,
5114
+ { params }: { params: Promise<{ path: string[] }> }
5115
+ ) {
5116
+ return proxyRequest(request, await params);
5117
+ }
5118
+
5119
+ export async function POST(
5120
+ request: NextRequest,
5121
+ { params }: { params: Promise<{ path: string[] }> }
5122
+ ) {
5123
+ return proxyRequest(request, await params);
5124
+ }
5125
+
5126
+ export async function PUT(
5127
+ request: NextRequest,
5128
+ { params }: { params: Promise<{ path: string[] }> }
5129
+ ) {
5130
+ return proxyRequest(request, await params);
5131
+ }
5132
+
5133
+ export async function PATCH(
5134
+ request: NextRequest,
5135
+ { params }: { params: Promise<{ path: string[] }> }
5136
+ ) {
5137
+ return proxyRequest(request, await params);
5138
+ }
5139
+
5140
+ export async function DELETE(
5141
+ request: NextRequest,
5142
+ { params }: { params: Promise<{ path: string[] }> }
5143
+ ) {
5144
+ return proxyRequest(request, await params);
5145
+ }
5146
+ `,
5147
+ "src/app/api/auth/oauth-callback/route.ts": `import { NextRequest, NextResponse } from 'next/server';
5148
+
5149
+ const TOKEN_COOKIE = 'brainerce_customer_token';
5150
+ const LOGGED_IN_COOKIE = 'brainerce_logged_in';
5151
+ const COOKIE_MAX_AGE = 7 * 24 * 60 * 60; // 7 days
5152
+
5153
+ function isSecure(): boolean {
5154
+ return process.env.NODE_ENV === 'production';
5155
+ }
5156
+
5157
+ /**
5158
+ * OAuth callback handler.
5159
+ * The backend redirects here with ?token=jwt&oauth_success=true after OAuth code exchange.
5160
+ * We set the httpOnly cookie and redirect to the client-side callback page (without the token).
5161
+ */
5162
+ export async function GET(request: NextRequest) {
5163
+ const { searchParams } = request.nextUrl;
5164
+ const token = searchParams.get('token');
5165
+ const oauthSuccess = searchParams.get('oauth_success');
5166
+ const oauthError = searchParams.get('oauth_error');
5167
+
5168
+ // Build redirect URL to client-side callback page
5169
+ const redirectUrl = new URL('/auth/callback', request.url);
5170
+
5171
+ if (oauthError) {
5172
+ redirectUrl.searchParams.set('oauth_error', oauthError);
5173
+ return NextResponse.redirect(redirectUrl);
5174
+ }
5175
+
5176
+ if (oauthSuccess === 'true' && token) {
5177
+ redirectUrl.searchParams.set('oauth_success', 'true');
5178
+
5179
+ const response = NextResponse.redirect(redirectUrl);
5180
+
5181
+ // Set httpOnly cookie with the token
5182
+ response.cookies.set(TOKEN_COOKIE, token, {
5183
+ httpOnly: true,
5184
+ secure: isSecure(),
5185
+ sameSite: 'lax',
5186
+ path: '/',
5187
+ maxAge: COOKIE_MAX_AGE,
5188
+ });
5189
+
5190
+ // Set indicator cookie (readable by client JS)
5191
+ response.cookies.set(LOGGED_IN_COOKIE, '1', {
5192
+ httpOnly: false,
5193
+ secure: isSecure(),
5194
+ sameSite: 'lax',
5195
+ path: '/',
5196
+ maxAge: COOKIE_MAX_AGE,
5197
+ });
5198
+
5199
+ return response;
5200
+ }
5201
+
5202
+ // Fallback: no token or success flag
5203
+ redirectUrl.searchParams.set('oauth_error', 'Authentication failed');
5204
+ return NextResponse.redirect(redirectUrl);
5205
+ }
5206
+ `,
5207
+ "src/app/api/auth/me/route.ts": `import { NextResponse } from 'next/server';
5208
+ import { cookies } from 'next/headers';
5209
+
5210
+ const BACKEND_URL = (process.env.BRAINERCE_API_URL || 'https://api.brainerce.com').replace(
5211
+ /\\/$/,
5212
+ ''
5213
+ );
5214
+
5215
+ const CONNECTION_ID = process.env.NEXT_PUBLIC_BRAINERCE_CONNECTION_ID || '';
5216
+
5217
+ const TOKEN_COOKIE = 'brainerce_customer_token';
5218
+ const LOGGED_IN_COOKIE = 'brainerce_logged_in';
5219
+
5220
+ /**
5221
+ * Auth status check endpoint.
5222
+ * Reads the httpOnly cookie, validates against backend, returns auth state.
5223
+ */
5224
+ export async function GET() {
5225
+ const cookieStore = await cookies();
5226
+ const tokenCookie = cookieStore.get(TOKEN_COOKIE);
5227
+
5228
+ if (!tokenCookie?.value) {
5229
+ return NextResponse.json({ isLoggedIn: false });
5230
+ }
5231
+
5232
+ try {
5233
+ // Validate token by calling backend profile endpoint
5234
+ const response = await fetch(\`\${BACKEND_URL}/api/vc/\${CONNECTION_ID}/customers/me\`, {
5235
+ headers: {
5236
+ Authorization: \`Bearer \${tokenCookie.value}\`,
5237
+ 'Content-Type': 'application/json',
5238
+ },
5239
+ });
5240
+
5241
+ if (!response.ok) {
5242
+ // Token is invalid or expired \u2014 clear cookies
5243
+ const res = NextResponse.json({ isLoggedIn: false });
5244
+ res.cookies.delete(TOKEN_COOKIE);
5245
+ res.cookies.delete(LOGGED_IN_COOKIE);
5246
+ return res;
5247
+ }
5248
+
5249
+ const customer = await response.json();
5250
+ return NextResponse.json({ isLoggedIn: true, customer });
5251
+ } catch {
5252
+ // Backend unreachable \u2014 don't clear cookies, might be temporary
5253
+ return NextResponse.json({ isLoggedIn: false, error: 'Service unavailable' }, { status: 503 });
5254
+ }
5255
+ }
5256
+ `,
5257
+ "src/app/api/auth/logout/route.ts": `import { NextResponse } from 'next/server';
5258
+
5259
+ const TOKEN_COOKIE = 'brainerce_customer_token';
5260
+ const LOGGED_IN_COOKIE = 'brainerce_logged_in';
5261
+
5262
+ /**
5263
+ * Logout endpoint. Clears auth cookies.
5264
+ */
5265
+ export async function POST() {
5266
+ const response = NextResponse.json({ success: true });
5267
+ response.cookies.delete(TOKEN_COOKIE);
5268
+ response.cookies.delete(LOGGED_IN_COOKIE);
5269
+ return response;
5270
+ }
5271
+ `,
5272
+ "src/middleware.ts": `import { NextRequest, NextResponse } from 'next/server';
5273
+
5274
+ const TOKEN_COOKIE = 'brainerce_customer_token';
5275
+
5276
+ /** Routes that require customer authentication */
5277
+ const PROTECTED_PATHS = ['/account'];
5278
+
5279
+ export function middleware(request: NextRequest) {
5280
+ const { pathname } = request.nextUrl;
5281
+ const isProtected = PROTECTED_PATHS.some((p) => pathname.startsWith(p));
5282
+
5283
+ if (isProtected) {
5284
+ const token = request.cookies.get(TOKEN_COOKIE);
5285
+ if (!token?.value) {
5286
+ const loginUrl = new URL('/login', request.url);
5287
+ return NextResponse.redirect(loginUrl);
5288
+ }
5289
+ }
5290
+
5291
+ return NextResponse.next();
5292
+ }
5293
+
5294
+ export const config = {
5295
+ matcher: ['/account/:path*'],
5296
+ };
4542
5297
  `,
4543
5298
  "src/components/products/product-card.tsx": `'use client';
4544
5299
 
@@ -4559,6 +5314,7 @@ interface ProductCardProps {
4559
5314
 
4560
5315
  export function ProductCard({ product, className }: ProductCardProps) {
4561
5316
  const t = useTranslations('common');
5317
+ const tp = useTranslations('productDetail');
4562
5318
  const { price, originalPrice, isOnSale } = getProductPriceInfo(product);
4563
5319
  const mainImage = product.images?.[0];
4564
5320
  const imageUrl = mainImage?.url || null;
@@ -4603,6 +5359,11 @@ export function ProductCard({ product, className }: ProductCardProps) {
4603
5359
  </span>
4604
5360
  )}
4605
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
+ )}
4606
5367
  </div>
4607
5368
  </div>
4608
5369
 
@@ -5870,8 +6631,11 @@ declare global {
5870
6631
  }
5871
6632
 
5872
6633
  // Payment SDK script URLs \u2014 resolved per provider
6634
+ // SRI hashes must be regenerated when the provider updates their SDK
5873
6635
  const PAYMENT_SDK_URL = 'https://cdn.meshulam.co.il/sdk/gs.min.js';
6636
+ const PAYMENT_SDK_SRI = 'sha384-e1OYzZERZLgbR8Zw1I4Ww/J7qXGyEsZb7LF0z5W/sbnTsFwtxop7sKZsLGNp6CB1';
5874
6637
  const APPLE_PAY_SDK_URL = 'https://meshulam.co.il/_media/js/apple_pay_sdk/sdk.min.js';
6638
+ const APPLE_PAY_SDK_SRI = 'sha384-CG67HXmWBeyuRR/jqFsYr6dGEMFyuZw3/hRmcyACJRYHGb/1ebEkQZyzdJXDc9/u';
5875
6639
 
5876
6640
  interface PaymentStepProps {
5877
6641
  checkoutId: string;
@@ -5882,7 +6646,7 @@ interface PaymentStepProps {
5882
6646
  * Load a script tag if not already present in the DOM.
5883
6647
  * Resolves when the script loads (or immediately if already present).
5884
6648
  */
5885
- function loadScript(src: string, optional = false): Promise<void> {
6649
+ function loadScript(src: string, optional = false, integrity?: string): Promise<void> {
5886
6650
  return new Promise((resolve) => {
5887
6651
  if (document.querySelector(\`script[src="\${src}"]\`)) {
5888
6652
  resolve();
@@ -5891,6 +6655,10 @@ function loadScript(src: string, optional = false): Promise<void> {
5891
6655
  const script = document.createElement('script');
5892
6656
  script.src = src;
5893
6657
  script.async = true;
6658
+ if (integrity) {
6659
+ script.integrity = integrity;
6660
+ script.crossOrigin = 'anonymous';
6661
+ }
5894
6662
  script.onload = () => resolve();
5895
6663
  script.onerror = () => {
5896
6664
  if (optional) {
@@ -5983,10 +6751,10 @@ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
5983
6751
 
5984
6752
  async function loadSdkScripts() {
5985
6753
  // Load Apple Pay SDK (optional)
5986
- await loadScript(APPLE_PAY_SDK_URL, true);
6754
+ await loadScript(APPLE_PAY_SDK_URL, true, APPLE_PAY_SDK_SRI);
5987
6755
 
5988
6756
  // Load payment SDK script
5989
- await loadScript(PAYMENT_SDK_URL);
6757
+ await loadScript(PAYMENT_SDK_URL, false, PAYMENT_SDK_SRI);
5990
6758
 
5991
6759
  // Wait for SDK global to be set by the script
5992
6760
  const available = await waitForPaymentSdkGlobal();
@@ -6649,7 +7417,7 @@ export function OAuthButtons({ className }: OAuthButtonsProps) {
6649
7417
  try {
6650
7418
  setRedirecting(provider);
6651
7419
  const client = getClient();
6652
- const redirectUrl = window.location.origin + '/auth/callback';
7420
+ const redirectUrl = window.location.origin + '/api/auth/oauth-callback';
6653
7421
  const result = await client.getOAuthAuthorizeUrl(provider, { redirectUrl });
6654
7422
  window.location.href = result.authorizationUrl;
6655
7423
  } catch (err) {
@@ -6931,10 +7699,11 @@ export function ProfileSection({ profile, onProfileUpdate, className }: ProfileS
6931
7699
  `,
6932
7700
  "src/components/account/order-history.tsx": `'use client';
6933
7701
 
6934
- import { useState } from 'react';
7702
+ import { useState, useEffect } from 'react';
6935
7703
  import Image from 'next/image';
6936
- import type { Order, OrderStatus } from 'brainerce';
7704
+ import type { Order, OrderStatus, OrderDownloadLink } from 'brainerce';
6937
7705
  import { formatPrice } from 'brainerce';
7706
+ import { getClient } from '@/lib/brainerce';
6938
7707
  import { useTranslations } from '@/lib/translations';
6939
7708
  import { cn } from '@/lib/utils';
6940
7709
 
@@ -7118,6 +7887,11 @@ function OrderCard({ order }: { order: Order }) {
7118
7887
  </div>
7119
7888
  ))}
7120
7889
 
7890
+ {/* Downloads section */}
7891
+ {order.hasDownloads && (
7892
+ <OrderDownloads orderId={order.id} />
7893
+ )}
7894
+
7121
7895
  <OrderFinancialSummary order={order} currency={currency} />
7122
7896
  </div>
7123
7897
  )}
@@ -7125,6 +7899,71 @@ function OrderCard({ order }: { order: Order }) {
7125
7899
  );
7126
7900
  }
7127
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
+
7128
7967
  function OrderFinancialSummary({ order, currency }: { order: Order; currency: string }) {
7129
7968
  const tc = useTranslations('common');
7130
7969
  const totalAmount = order.totalAmount || order.total || '0';
@@ -8006,7 +8845,7 @@ var DETAILED = `# Required Pages \u2014 Detailed Guide
8006
8845
  - Stock badge
8007
8846
  - Discount badge (\`getProductDiscountBadge()\`)
8008
8847
  - Add to cart button (disabled when \`!canPurchase\`)
8009
- - Product recommendations (\`getProductRecommendations()\`)
8848
+ - Product recommendations (from \`product.recommendations\` \u2014 embedded in product response)
8010
8849
  - Use \`client.getProductBySlug(slug)\`
8011
8850
 
8012
8851
  ## 4. Cart (\`/cart\`)
@@ -8284,6 +9123,39 @@ my-store/
8284
9123
  | \`app/auth/callback/page.tsx\` | Extracts OAuth token from URL params, stores in localStorage |
8285
9124
  | \`app/account/page.tsx\` | Protected page: getMyProfile() + getMyOrders() |
8286
9125
  | \`components/Header.tsx\` | Site header with nav, cart count badge, search autocomplete |
9126
+
9127
+ ## Content Security Policy (CSP)
9128
+
9129
+ Payment SDKs load iframes and scripts from external domains. Add these CSP headers in \`next.config.js\`:
9130
+
9131
+ \`\`\`js
9132
+ // next.config.js
9133
+ module.exports = {
9134
+ async headers() {
9135
+ return [{
9136
+ source: '/(.*)',
9137
+ headers: [{
9138
+ key: 'Content-Security-Policy',
9139
+ value: [
9140
+ "default-src 'self'",
9141
+ "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.meshulam.co.il https://meshulam.co.il https://*.meshulam.co.il https://grow.link https://*.grow.link https://*.grow.security https://js.stripe.com https://pay.google.com",
9142
+ "style-src 'self' 'unsafe-inline'",
9143
+ "img-src 'self' data: blob: https:",
9144
+ "font-src 'self' data:",
9145
+ "frame-src 'self' https://*.meshulam.co.il https://grow.link https://*.grow.link https://*.grow.security https://*.creditguard.co.il https://js.stripe.com https://hooks.stripe.com https://pay.google.com",
9146
+ "connect-src 'self' https://*.meshulam.co.il https://grow.link https://*.grow.link https://*.grow.security https://google.com https://pay.google.com https://*.stripe.com https://*.creditguard.co.il",
9147
+ "worker-src 'self' blob:",
9148
+ ].join('; '),
9149
+ }],
9150
+ }];
9151
+ },
9152
+ };
9153
+ \`\`\`
9154
+
9155
+ **Required domains by provider:**
9156
+ - **Grow**: \`*.meshulam.co.il\`, \`grow.link\`, \`*.grow.link\`, \`*.grow.security\`, \`*.creditguard.co.il\`
9157
+ - **Stripe**: \`js.stripe.com\`, \`hooks.stripe.com\`, \`*.stripe.com\`
9158
+ - **Google Pay**: \`pay.google.com\`, \`google.com\`
8287
9159
  `;
8288
9160
 
8289
9161
  // src/resources/project-template.ts