@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/index.mjs CHANGED
@@ -685,20 +685,25 @@ Display: banners in header, badges on product cards (strikethrough + discounted
685
685
  function getRecommendationsSection() {
686
686
  return `## Product Recommendations (Cross-Sells & Upsells)
687
687
 
688
+ Recommendations come **embedded in the product response** \u2014 no extra API call needed for product pages.
689
+
688
690
  \`\`\`typescript
689
691
  import type { ProductRecommendation, ProductRecommendationsResponse, CartRecommendationsResponse } from 'brainerce';
690
692
 
691
- // Product page \u2014 cross-sells, upsells, related
692
- const recs: ProductRecommendationsResponse = await client.getProductRecommendations(productId);
693
- // recs.crossSells, recs.upsells, recs.related
693
+ // Product page \u2014 recommendations are embedded in the product response
694
+ const product = await client.getProductBySlug('some-slug');
695
+ const recs = (product as any).recommendations as ProductRecommendationsResponse | undefined;
696
+ // recs?.upsells \u2014 premium alternatives (show on product page)
697
+ // recs?.related \u2014 similar products (show at bottom of product page)
698
+ // recs?.crossSells \u2014 complementary products (typically used on cart page)
694
699
 
695
- // Cart page \u2014 cross-sell suggestions for cart items
700
+ // Cart page \u2014 cross-sell suggestions for cart items (separate call)
696
701
  const cartRecs: CartRecommendationsResponse = await client.getCartRecommendations(cartId, 4);
697
- // cartRecs.recommendations \u2014 deduplicated cross-sells
702
+ // cartRecs.recommendations \u2014 deduplicated cross-sells (excludes items already in cart)
698
703
  \`\`\`
699
704
 
700
705
  ProductRecommendation: \`id\`, \`name\`, \`slug\`, \`basePrice\`, \`salePrice\`, \`images\`, \`type\`, \`inventory\`, \`relationType\`.
701
- Display "You might also like" on product pages, "Recommended for you" in cart.`;
706
+ Display upsells + related on product pages, cross-sells on cart page.`;
702
707
  }
703
708
  function getInventorySection() {
704
709
  return `## Inventory, Stock Display & Reservation Countdown
@@ -1867,39 +1872,24 @@ var EMBEDDED_TEMPLATES = {
1867
1872
  "src/lib/brainerce.ts.ejs": `import { BrainerceClient } from 'brainerce';
1868
1873
 
1869
1874
  const CONNECTION_ID = process.env.NEXT_PUBLIC_BRAINERCE_CONNECTION_ID || 'vc_YOUR_CONNECTION_ID';
1870
- const API_URL = process.env.NEXT_PUBLIC_BRAINERCE_API_URL || 'https://api.brainerce.com';
1871
1875
 
1872
- // Singleton SDK client
1876
+ // Singleton SDK client \u2014 routes through same-origin BFF proxy for httpOnly cookie auth
1873
1877
  let clientInstance: BrainerceClient | null = null;
1874
1878
 
1875
1879
  export function getClient(): BrainerceClient {
1876
1880
  if (!clientInstance) {
1877
1881
  clientInstance = new BrainerceClient({
1878
1882
  connectionId: CONNECTION_ID,
1879
- baseUrl: API_URL,
1883
+ baseUrl: '/api/store', // same-origin proxy handles auth via httpOnly cookie
1884
+ proxyMode: true, // skip client-side token checks; proxy adds Authorization header
1880
1885
  });
1881
1886
  }
1882
1887
  return clientInstance;
1883
1888
  }
1884
1889
 
1885
- // Auth token helpers
1886
- const TOKEN_KEY = 'brainerce_customer_token';
1890
+ // Cart ID helpers (not a security token \u2014 safe in localStorage)
1887
1891
  const CART_ID_KEY = 'brainerce_cart_id';
1888
1892
 
1889
- export function getStoredToken(): string | null {
1890
- if (typeof window === 'undefined') return null;
1891
- return localStorage.getItem(TOKEN_KEY);
1892
- }
1893
-
1894
- export function setStoredToken(token: string | null): void {
1895
- if (typeof window === 'undefined') return;
1896
- if (token) {
1897
- localStorage.setItem(TOKEN_KEY, token);
1898
- } else {
1899
- localStorage.removeItem(TOKEN_KEY);
1900
- }
1901
- }
1902
-
1903
1893
  export function getStoredCartId(): string | null {
1904
1894
  if (typeof window === 'undefined') return null;
1905
1895
  return localStorage.getItem(CART_ID_KEY);
@@ -1914,14 +1904,158 @@ export function setStoredCartId(cartId: string | null): void {
1914
1904
  }
1915
1905
  }
1916
1906
 
1917
- // Initialize client with stored auth
1907
+ // Initialize client (no token hydration \u2014 auth handled by httpOnly cookie)
1918
1908
  export function initClient(): BrainerceClient {
1919
- const client = getClient();
1920
- const token = getStoredToken();
1921
- if (token) {
1922
- client.setCustomerToken(token);
1909
+ return getClient();
1910
+ }
1911
+ `,
1912
+ "src/lib/auth.ts": `/**
1913
+ * Client-side auth helpers that call the BFF proxy API routes.
1914
+ * All mutating requests include the CSRF header.
1915
+ * The token is managed server-side via httpOnly cookies \u2014 never exposed to JS.
1916
+ */
1917
+
1918
+ const CONNECTION_ID = process.env.NEXT_PUBLIC_BRAINERCE_CONNECTION_ID || '';
1919
+
1920
+ const CSRF_HEADERS: Record<string, string> = {
1921
+ 'Content-Type': 'application/json',
1922
+ 'X-Requested-With': 'brainerce',
1923
+ };
1924
+
1925
+ interface LoginResult {
1926
+ customer: {
1927
+ id: string;
1928
+ email: string;
1929
+ firstName?: string;
1930
+ lastName?: string;
1931
+ emailVerified: boolean;
1932
+ };
1933
+ expiresAt: string;
1934
+ requiresVerification?: boolean;
1935
+ }
1936
+
1937
+ interface RegisterResult {
1938
+ customer: {
1939
+ id: string;
1940
+ email: string;
1941
+ firstName?: string;
1942
+ lastName?: string;
1943
+ emailVerified: boolean;
1944
+ };
1945
+ expiresAt: string;
1946
+ requiresVerification?: boolean;
1947
+ }
1948
+
1949
+ interface AuthStatus {
1950
+ isLoggedIn: boolean;
1951
+ customer?: {
1952
+ id: string;
1953
+ email: string;
1954
+ firstName?: string;
1955
+ lastName?: string;
1956
+ phone?: string;
1957
+ emailVerified: boolean;
1958
+ };
1959
+ error?: string;
1960
+ }
1961
+
1962
+ interface VerifyEmailResult {
1963
+ verified: boolean;
1964
+ message?: string;
1965
+ }
1966
+
1967
+ async function handleResponse<T>(response: Response): Promise<T> {
1968
+ const data = await response.json();
1969
+ if (!response.ok) {
1970
+ throw new Error(data.message || data.error || \`Request failed (\${response.status})\`);
1923
1971
  }
1924
- return client;
1972
+ return data as T;
1973
+ }
1974
+
1975
+ /**
1976
+ * Login via BFF proxy. The proxy sets the httpOnly cookie on success.
1977
+ */
1978
+ export async function proxyLogin(email: string, password: string): Promise<LoginResult> {
1979
+ const response = await fetch(\`/api/store/api/vc/\${CONNECTION_ID}/customers/login\`, {
1980
+ method: 'POST',
1981
+ headers: CSRF_HEADERS,
1982
+ body: JSON.stringify({ email, password }),
1983
+ });
1984
+ return handleResponse<LoginResult>(response);
1985
+ }
1986
+
1987
+ /**
1988
+ * Register via BFF proxy. The proxy sets the httpOnly cookie on success.
1989
+ */
1990
+ export async function proxyRegister(data: {
1991
+ firstName: string;
1992
+ lastName: string;
1993
+ email: string;
1994
+ password: string;
1995
+ }): Promise<RegisterResult> {
1996
+ const response = await fetch(\`/api/store/api/vc/\${CONNECTION_ID}/customers/register\`, {
1997
+ method: 'POST',
1998
+ headers: CSRF_HEADERS,
1999
+ body: JSON.stringify(data),
2000
+ });
2001
+ return handleResponse<RegisterResult>(response);
2002
+ }
2003
+
2004
+ /**
2005
+ * Check auth status. Reads httpOnly cookie server-side and validates with backend.
2006
+ */
2007
+ export async function checkAuthStatus(): Promise<AuthStatus> {
2008
+ const response = await fetch('/api/auth/me');
2009
+ return response.json();
2010
+ }
2011
+
2012
+ /**
2013
+ * Logout. Clears httpOnly auth cookies server-side.
2014
+ */
2015
+ export async function proxyLogout(): Promise<void> {
2016
+ await fetch('/api/auth/logout', {
2017
+ method: 'POST',
2018
+ headers: { 'X-Requested-With': 'brainerce' },
2019
+ });
2020
+ }
2021
+
2022
+ /**
2023
+ * Verify email via BFF proxy. The auth token is in the httpOnly cookie (set during login/register).
2024
+ * The proxy adds the Authorization header automatically.
2025
+ */
2026
+ export async function proxyVerifyEmail(code: string): Promise<VerifyEmailResult> {
2027
+ const response = await fetch(\`/api/store/api/vc/\${CONNECTION_ID}/customers/verify-email\`, {
2028
+ method: 'POST',
2029
+ headers: CSRF_HEADERS,
2030
+ body: JSON.stringify({ code }),
2031
+ });
2032
+ return handleResponse<VerifyEmailResult>(response);
2033
+ }
2034
+
2035
+ /**
2036
+ * Resend verification email via BFF proxy.
2037
+ * Uses the auth token from the httpOnly cookie.
2038
+ */
2039
+ export async function proxyResendVerification(): Promise<{ message: string }> {
2040
+ const response = await fetch(\`/api/store/api/vc/\${CONNECTION_ID}/customers/resend-verification\`, {
2041
+ method: 'POST',
2042
+ headers: CSRF_HEADERS,
2043
+ });
2044
+ return handleResponse<{ message: string }>(response);
2045
+ }
2046
+
2047
+ /**
2048
+ * Reset password via BFF proxy.
2049
+ * The reset token is in an httpOnly cookie (set by /api/auth/reset-callback when the user
2050
+ * clicked the email link). The proxy reads it server-side \u2014 the token never reaches client JS.
2051
+ */
2052
+ export async function proxyResetPassword(newPassword: string): Promise<{ message: string }> {
2053
+ const response = await fetch('/api/auth/reset-password', {
2054
+ method: 'POST',
2055
+ headers: CSRF_HEADERS,
2056
+ body: JSON.stringify({ newPassword }),
2057
+ });
2058
+ return handleResponse<{ message: string }>(response);
1925
2059
  }
1926
2060
  `,
1927
2061
  "src/lib/utils.ts": `import { clsx, type ClassValue } from 'clsx';
@@ -1934,9 +2068,10 @@ export function cn(...inputs: ClassValue[]) {
1934
2068
  "src/providers/store-provider.tsx.ejs": `'use client';
1935
2069
 
1936
2070
  import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
1937
- import type { StoreInfo, Cart } from 'brainerce';
2071
+ import type { StoreInfo, Cart, CustomerProfile } from 'brainerce';
1938
2072
  import { getCartTotals } from 'brainerce';
1939
- import { getClient, initClient, getStoredToken, setStoredToken, setStoredCartId } from '@/lib/brainerce';
2073
+ import { getClient, initClient, setStoredCartId } from '@/lib/brainerce';
2074
+ import { checkAuthStatus, proxyLogout } from '@/lib/auth';
1940
2075
 
1941
2076
  // ---- Store Info Context ----
1942
2077
  interface StoreInfoContextValue {
@@ -1957,17 +2092,17 @@ export function useStoreInfo() {
1957
2092
  interface AuthContextValue {
1958
2093
  isLoggedIn: boolean;
1959
2094
  authLoading: boolean;
1960
- token: string | null;
1961
- login: (token: string) => void;
1962
- logout: () => void;
2095
+ customer: CustomerProfile | null;
2096
+ login: () => Promise<void>;
2097
+ logout: () => Promise<void>;
1963
2098
  }
1964
2099
 
1965
2100
  const AuthContext = createContext<AuthContextValue>({
1966
2101
  isLoggedIn: false,
1967
2102
  authLoading: true,
1968
- token: null,
1969
- login: () => {},
1970
- logout: () => {},
2103
+ customer: null,
2104
+ login: async () => {},
2105
+ logout: async () => {},
1971
2106
  });
1972
2107
 
1973
2108
  export function useAuth() {
@@ -1999,26 +2134,46 @@ export function useCart() {
1999
2134
  export function StoreProvider({ children }: { children: React.ReactNode }) {
2000
2135
  const [storeInfo, setStoreInfo] = useState<StoreInfo | null>(null);
2001
2136
  const [storeLoading, setStoreLoading] = useState(true);
2002
- const [token, setToken] = useState<string | null>(null);
2137
+ const [isLoggedIn, setIsLoggedIn] = useState(false);
2138
+ const [customer, setCustomer] = useState<CustomerProfile | null>(null);
2003
2139
  const [authLoading, setAuthLoading] = useState(true);
2004
2140
  const [cart, setCart] = useState<Cart | null>(null);
2005
2141
  const [cartLoading, setCartLoading] = useState(true);
2006
2142
 
2007
- // Initialize client and auth
2143
+ // Check auth status via httpOnly cookie (server-side validation)
2144
+ const refreshAuth = useCallback(async () => {
2145
+ try {
2146
+ const status = await checkAuthStatus();
2147
+ setIsLoggedIn(status.isLoggedIn);
2148
+ setCustomer(status.isLoggedIn ? (status.customer as CustomerProfile) : null);
2149
+ } catch {
2150
+ setIsLoggedIn(false);
2151
+ setCustomer(null);
2152
+ } finally {
2153
+ setAuthLoading(false);
2154
+ }
2155
+ }, []);
2156
+
2157
+ // Initialize client, check auth, and fetch store info
2008
2158
  useEffect(() => {
2009
2159
  const client = initClient();
2010
- const stored = getStoredToken();
2011
- if (stored) {
2012
- setToken(stored);
2160
+
2161
+ // Optimistic check: if brainerce_logged_in cookie exists, assume logged in
2162
+ // while we validate the actual token server-side
2163
+ if (typeof document !== 'undefined' && document.cookie.includes('brainerce_logged_in=1')) {
2164
+ setIsLoggedIn(true);
2013
2165
  }
2014
- setAuthLoading(false);
2015
2166
 
2167
+ // Validate auth token server-side
2168
+ refreshAuth();
2169
+
2170
+ // Fetch store info (public, no auth needed)
2016
2171
  client
2017
2172
  .getStoreInfo()
2018
2173
  .then(setStoreInfo)
2019
2174
  .catch(console.error)
2020
2175
  .finally(() => setStoreLoading(false));
2021
- }, []);
2176
+ }, [refreshAuth]);
2022
2177
 
2023
2178
  // Cart management
2024
2179
  const refreshCart = useCallback(async () => {
@@ -2041,24 +2196,26 @@ export function StoreProvider({ children }: { children: React.ReactNode }) {
2041
2196
 
2042
2197
  useEffect(() => {
2043
2198
  refreshCart();
2044
- }, [refreshCart, token]);
2199
+ }, [refreshCart, isLoggedIn]);
2045
2200
 
2046
- const login = useCallback((newToken: string) => {
2047
- const client = getClient();
2048
- client.setCustomerToken(newToken);
2049
- setStoredToken(newToken);
2050
- setToken(newToken);
2201
+ // Called after successful login (cookie already set by proxy)
2202
+ const login = useCallback(async () => {
2203
+ // Refresh auth state from server (reads httpOnly cookie)
2204
+ await refreshAuth();
2051
2205
 
2052
2206
  // Merge guest session cart into customer cart
2207
+ const client = getClient();
2053
2208
  client.syncCartOnLogin().catch(console.error);
2054
- }, []);
2209
+ }, [refreshAuth]);
2210
+
2211
+ const logout = useCallback(async () => {
2212
+ // Clear httpOnly cookie server-side
2213
+ await proxyLogout();
2055
2214
 
2056
- const logout = useCallback(() => {
2057
2215
  const client = getClient();
2058
- client.clearCustomerToken();
2059
2216
  client.onLogout();
2060
- setStoredToken(null);
2061
- setToken(null);
2217
+ setIsLoggedIn(false);
2218
+ setCustomer(null);
2062
2219
  setCart(null);
2063
2220
  refreshCart();
2064
2221
  }, [refreshCart]);
@@ -2071,7 +2228,7 @@ export function StoreProvider({ children }: { children: React.ReactNode }) {
2071
2228
 
2072
2229
  return (
2073
2230
  <StoreInfoContext.Provider value={{ storeInfo, loading: storeLoading }}>
2074
- <AuthContext.Provider value={{ isLoggedIn: !!token, authLoading, token, login, logout }}>
2231
+ <AuthContext.Provider value={{ isLoggedIn, authLoading, customer, login, logout }}>
2075
2232
  <CartContext.Provider
2076
2233
  value={{ cart, cartLoading, refreshCart, itemCount, totals }}
2077
2234
  >
@@ -2220,7 +2377,7 @@ export default function RootLayout({
2220
2377
  `,
2221
2378
  "src/app/products/page.tsx": `'use client';
2222
2379
 
2223
- import { Suspense, useEffect, useState, useCallback } from 'react';
2380
+ import { Suspense, useEffect, useState, useCallback, useRef } from 'react';
2224
2381
  import { useSearchParams, useRouter } from 'next/navigation';
2225
2382
  import type { Product } from 'brainerce';
2226
2383
  import type { ProductQueryParams } from 'brainerce';
@@ -2246,9 +2403,198 @@ const sortOptions: SortOption[] = [
2246
2403
  { labelKey: 'sortPriceHigh', sortBy: 'price', sortOrder: 'desc' },
2247
2404
  ];
2248
2405
 
2249
- interface CategoryFilter {
2406
+ interface CategoryNode {
2250
2407
  id: string;
2251
2408
  name: string;
2409
+ parentId?: string | null;
2410
+ children: CategoryNode[];
2411
+ }
2412
+
2413
+ /** Collect all descendant IDs (including self) */
2414
+ function getAllDescendantIds(node: CategoryNode): string[] {
2415
+ const ids = [node.id];
2416
+ for (const child of node.children) {
2417
+ ids.push(...getAllDescendantIds(child));
2418
+ }
2419
+ return ids;
2420
+ }
2421
+
2422
+ /** Check if a category or any of its descendants matches the selected ID */
2423
+ function isActiveInTree(node: CategoryNode, selectedId: string): boolean {
2424
+ if (node.id === selectedId) return true;
2425
+ return node.children.some((child) => isActiveInTree(child, selectedId));
2426
+ }
2427
+
2428
+ /** Chevron down SVG */
2429
+ function ChevronDown({ className }: { className?: string }) {
2430
+ return (
2431
+ <svg
2432
+ className={className}
2433
+ width="12"
2434
+ height="12"
2435
+ viewBox="0 0 12 12"
2436
+ fill="none"
2437
+ stroke="currentColor"
2438
+ strokeWidth="2"
2439
+ strokeLinecap="round"
2440
+ strokeLinejoin="round"
2441
+ >
2442
+ <path d="M3 4.5L6 7.5L9 4.5" />
2443
+ </svg>
2444
+ );
2445
+ }
2446
+
2447
+ /** Recursive dropdown items for nested categories */
2448
+ function CategoryDropdownItems({
2449
+ children,
2450
+ depth,
2451
+ selectedId,
2452
+ onSelect,
2453
+ }: {
2454
+ children: CategoryNode[];
2455
+ depth: number;
2456
+ selectedId: string;
2457
+ onSelect: (id: string) => void;
2458
+ }) {
2459
+ return (
2460
+ <>
2461
+ {children.map((child) => (
2462
+ <div key={child.id}>
2463
+ <button
2464
+ onClick={() => onSelect(child.id)}
2465
+ className={cn(
2466
+ 'w-full text-start px-4 py-2 text-sm transition-colors hover:bg-muted',
2467
+ selectedId === child.id && 'bg-primary/10 text-primary font-medium'
2468
+ )}
2469
+ style={{ paddingInlineStart: \`\${(depth + 1) * 16}px\` }}
2470
+ >
2471
+ {child.name}
2472
+ </button>
2473
+ {child.children.length > 0 && (
2474
+ <CategoryDropdownItems
2475
+ children={child.children}
2476
+ depth={depth + 1}
2477
+ selectedId={selectedId}
2478
+ onSelect={onSelect}
2479
+ />
2480
+ )}
2481
+ </div>
2482
+ ))}
2483
+ </>
2484
+ );
2485
+ }
2486
+
2487
+ /** Category chip with dropdown for subcategories */
2488
+ function CategoryChip({
2489
+ category,
2490
+ selectedId,
2491
+ onSelect,
2492
+ tc,
2493
+ }: {
2494
+ category: CategoryNode;
2495
+ selectedId: string;
2496
+ onSelect: (id: string) => void;
2497
+ tc: (key: string) => string;
2498
+ }) {
2499
+ const [open, setOpen] = useState(false);
2500
+ const ref = useRef<HTMLDivElement>(null);
2501
+ const hasChildren = category.children.length > 0;
2502
+ const isActive = isActiveInTree(category, selectedId);
2503
+
2504
+ // Find the display name for the selected subcategory
2505
+ function findName(nodes: CategoryNode[], id: string): string | null {
2506
+ for (const n of nodes) {
2507
+ if (n.id === id) return n.name;
2508
+ const found = findName(n.children, id);
2509
+ if (found) return found;
2510
+ }
2511
+ return null;
2512
+ }
2513
+
2514
+ const selectedChildName =
2515
+ isActive && selectedId !== category.id ? findName(category.children, selectedId) : null;
2516
+
2517
+ // Close dropdown on outside click
2518
+ useEffect(() => {
2519
+ if (!open) return;
2520
+ function handleClick(e: MouseEvent) {
2521
+ if (ref.current && !ref.current.contains(e.target as Node)) {
2522
+ setOpen(false);
2523
+ }
2524
+ }
2525
+ document.addEventListener('mousedown', handleClick);
2526
+ return () => document.removeEventListener('mousedown', handleClick);
2527
+ }, [open]);
2528
+
2529
+ if (!hasChildren) {
2530
+ return (
2531
+ <button
2532
+ onClick={() => onSelect(category.id)}
2533
+ className={cn(
2534
+ 'rounded-full border px-3 py-1.5 text-sm transition-colors',
2535
+ selectedId === category.id
2536
+ ? 'bg-primary text-primary-foreground border-primary'
2537
+ : 'border-border text-muted-foreground hover:border-primary hover:text-foreground'
2538
+ )}
2539
+ >
2540
+ {category.name}
2541
+ </button>
2542
+ );
2543
+ }
2544
+
2545
+ return (
2546
+ <div ref={ref} className="relative">
2547
+ <button
2548
+ onClick={() => setOpen((prev) => !prev)}
2549
+ className={cn(
2550
+ 'inline-flex items-center gap-1 rounded-full border px-3 py-1.5 text-sm transition-colors',
2551
+ isActive
2552
+ ? 'bg-primary text-primary-foreground border-primary'
2553
+ : 'border-border text-muted-foreground hover:border-primary hover:text-foreground'
2554
+ )}
2555
+ >
2556
+ {category.name}
2557
+ {selectedChildName && (
2558
+ <span className="opacity-80">
2559
+ {'\xB7'} {selectedChildName}
2560
+ </span>
2561
+ )}
2562
+ <ChevronDown
2563
+ className={cn('transition-transform ms-0.5', open && 'rotate-180')}
2564
+ />
2565
+ </button>
2566
+
2567
+ {open && (
2568
+ <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">
2569
+ {/* "All in [category]" option */}
2570
+ <button
2571
+ onClick={() => {
2572
+ onSelect(category.id);
2573
+ setOpen(false);
2574
+ }}
2575
+ className={cn(
2576
+ 'w-full text-start px-4 py-2 text-sm font-medium transition-colors hover:bg-muted',
2577
+ selectedId === category.id && 'bg-primary/10 text-primary'
2578
+ )}
2579
+ >
2580
+ {tc('all')} {category.name}
2581
+ </button>
2582
+ <div className="bg-border mx-2 h-px" />
2583
+ {/* Recursive children */}
2584
+ <div
2585
+ onClick={() => setOpen(false)}
2586
+ >
2587
+ <CategoryDropdownItems
2588
+ children={category.children}
2589
+ depth={0}
2590
+ selectedId={selectedId}
2591
+ onSelect={onSelect}
2592
+ />
2593
+ </div>
2594
+ </div>
2595
+ )}
2596
+ </div>
2597
+ );
2252
2598
  }
2253
2599
 
2254
2600
  function ProductsContent() {
@@ -2267,18 +2613,18 @@ function ProductsContent() {
2267
2613
  const [page, setPage] = useState(1);
2268
2614
  const [totalPages, setTotalPages] = useState(1);
2269
2615
  const [total, setTotal] = useState(0);
2270
- const [categories, setCategories] = useState<CategoryFilter[]>([]);
2616
+ const [categories, setCategories] = useState<CategoryNode[]>([]);
2271
2617
 
2272
2618
  const sortIndex = parseInt(sortParam, 10) || 0;
2273
2619
  const currentSort = sortOptions[sortIndex] || sortOptions[0];
2274
2620
 
2275
- // Load categories
2621
+ // Load categories (keep tree structure)
2276
2622
  useEffect(() => {
2277
2623
  async function loadCategories() {
2278
2624
  try {
2279
2625
  const client = getClient();
2280
2626
  const result = await client.getCategories();
2281
- setCategories(result.categories.map((c) => ({ id: c.id, name: c.name })));
2627
+ setCategories(result.categories as CategoryNode[]);
2282
2628
  } catch {
2283
2629
  // Categories endpoint may not be available in all modes
2284
2630
  }
@@ -2347,6 +2693,10 @@ function ProductsContent() {
2347
2693
  router.push(\`/products?\${params.toString()}\`);
2348
2694
  }
2349
2695
 
2696
+ function handleCategorySelect(id: string) {
2697
+ updateParam('category', id);
2698
+ }
2699
+
2350
2700
  return (
2351
2701
  <div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
2352
2702
  {/* Page Header */}
@@ -2378,18 +2728,13 @@ function ProductsContent() {
2378
2728
  {tc('all')}
2379
2729
  </button>
2380
2730
  {categories.map((cat) => (
2381
- <button
2731
+ <CategoryChip
2382
2732
  key={cat.id}
2383
- onClick={() => updateParam('category', cat.id)}
2384
- className={cn(
2385
- 'rounded-full border px-3 py-1.5 text-sm transition-colors',
2386
- categoryId === cat.id
2387
- ? 'bg-primary text-primary-foreground border-primary'
2388
- : 'border-border text-muted-foreground hover:border-primary hover:text-foreground'
2389
- )}
2390
- >
2391
- {cat.name}
2392
- </button>
2733
+ category={cat}
2734
+ selectedId={categoryId}
2735
+ onSelect={handleCategorySelect}
2736
+ tc={tc as (key: string) => string}
2737
+ />
2393
2738
  ))}
2394
2739
  </div>
2395
2740
  )}
@@ -2471,7 +2816,18 @@ import { useEffect, useState, useMemo } from 'react';
2471
2816
  import { useParams } from 'next/navigation';
2472
2817
  import Image from 'next/image';
2473
2818
  import Link from 'next/link';
2474
- import type { Product, ProductVariant, ProductImage, ProductMetafield } from 'brainerce';
2819
+ import type {
2820
+ Product,
2821
+ ProductVariant,
2822
+ ProductImage,
2823
+ ProductMetafield,
2824
+ ProductRecommendationsResponse,
2825
+ DownloadFile,
2826
+ } from 'brainerce';
2827
+
2828
+ type ProductWithRecommendations = Product & {
2829
+ recommendations?: ProductRecommendationsResponse;
2830
+ };
2475
2831
  import { getProductPriceInfo, getDescriptionContent } from 'brainerce';
2476
2832
  import { getClient } from '@/lib/brainerce';
2477
2833
  import { useCart } from '@/providers/store-provider';
@@ -2479,6 +2835,7 @@ import { PriceDisplay } from '@/components/shared/price-display';
2479
2835
  import { LoadingSpinner } from '@/components/shared/loading-spinner';
2480
2836
  import { VariantSelector } from '@/components/products/variant-selector';
2481
2837
  import { StockBadge } from '@/components/products/stock-badge';
2838
+ import { RecommendationSection } from '@/components/products/recommendation-section';
2482
2839
  import { useTranslations } from '@/lib/translations';
2483
2840
  import { cn } from '@/lib/utils';
2484
2841
 
@@ -2572,7 +2929,7 @@ export default function ProductDetailPage() {
2572
2929
  const { refreshCart } = useCart();
2573
2930
  const t = useTranslations('productDetail');
2574
2931
  const tc = useTranslations('common');
2575
- const [product, setProduct] = useState<Product | null>(null);
2932
+ const [product, setProduct] = useState<ProductWithRecommendations | null>(null);
2576
2933
  const [loading, setLoading] = useState(true);
2577
2934
  const [error, setError] = useState<string | null>(null);
2578
2935
  const [selectedVariant, setSelectedVariant] = useState<ProductVariant | null>(null);
@@ -2581,6 +2938,8 @@ export default function ProductDetailPage() {
2581
2938
  const [addingToCart, setAddingToCart] = useState(false);
2582
2939
  const [addedMessage, setAddedMessage] = useState(false);
2583
2940
 
2941
+ const recommendations = product?.recommendations ?? null;
2942
+
2584
2943
  // Load product
2585
2944
  useEffect(() => {
2586
2945
  async function load() {
@@ -2589,7 +2948,7 @@ export default function ProductDetailPage() {
2589
2948
  setError(null);
2590
2949
  const client = getClient();
2591
2950
  const p = await client.getProductBySlug(slug);
2592
- setProduct(p);
2951
+ setProduct(p as ProductWithRecommendations);
2593
2952
 
2594
2953
  // Auto-select first variant
2595
2954
  if (p.variants && p.variants.length > 0) {
@@ -2795,8 +3154,43 @@ export default function ProductDetailPage() {
2795
3154
  size="lg"
2796
3155
  />
2797
3156
 
2798
- {/* Stock */}
2799
- <StockBadge inventory={inventory} lowStockThreshold={5} />
3157
+ {/* Stock / Digital badge */}
3158
+ {product.isDownloadable ? (
3159
+ <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">
3160
+ <svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
3161
+ <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" />
3162
+ </svg>
3163
+ {t('instantDownload')}
3164
+ </span>
3165
+ ) : (
3166
+ <StockBadge inventory={inventory} lowStockThreshold={5} />
3167
+ )}
3168
+
3169
+ {/* Downloadable files info */}
3170
+ {product.isDownloadable && product.downloads && product.downloads.length > 0 && (
3171
+ <div className="bg-muted/50 rounded-lg border p-4">
3172
+ <p className="text-foreground mb-2 text-sm font-medium">
3173
+ {t('filesIncluded', { count: product.downloads.length })}
3174
+ </p>
3175
+ <ul className="space-y-1.5">
3176
+ {product.downloads.map((file: DownloadFile) => (
3177
+ <li key={file.id} className="text-muted-foreground flex items-center gap-2 text-sm">
3178
+ <svg className="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
3179
+ <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" />
3180
+ </svg>
3181
+ <span className="truncate">{file.name}</span>
3182
+ {file.size && (
3183
+ <span className="flex-shrink-0 text-xs">
3184
+ ({file.size < 1024 * 1024
3185
+ ? \`\${(file.size / 1024).toFixed(0)} KB\`
3186
+ : \`\${(file.size / (1024 * 1024)).toFixed(1)} MB\`})
3187
+ </span>
3188
+ )}
3189
+ </li>
3190
+ ))}
3191
+ </ul>
3192
+ </div>
3193
+ )}
2800
3194
 
2801
3195
  {/* Variant Selector */}
2802
3196
  {product.type === 'VARIABLE' && product.variants && product.variants.length > 0 && (
@@ -2860,6 +3254,13 @@ export default function ProductDetailPage() {
2860
3254
  </button>
2861
3255
  </div>
2862
3256
 
3257
+ {/* Download after purchase note */}
3258
+ {product.isDownloadable && (
3259
+ <p className="text-muted-foreground text-sm">
3260
+ {t('downloadAfterPurchase')}
3261
+ </p>
3262
+ )}
3263
+
2863
3264
  {/* Description */}
2864
3265
  {description && (
2865
3266
  <div className="border-border border-t pt-4">
@@ -2897,19 +3298,41 @@ export default function ProductDetailPage() {
2897
3298
  )}
2898
3299
  </div>
2899
3300
  </div>
3301
+
3302
+ {/* Upsells \u2014 premium alternatives (product page) */}
3303
+ {recommendations?.upsells && recommendations.upsells.length > 0 && (
3304
+ <RecommendationSection
3305
+ title={t('upgradeYourChoice')}
3306
+ items={recommendations.upsells}
3307
+ className="mt-12"
3308
+ />
3309
+ )}
3310
+
3311
+ {/* Related products \u2014 similar items (bottom of product page) */}
3312
+ {recommendations?.related && recommendations.related.length > 0 && (
3313
+ <RecommendationSection
3314
+ title={t('similarProducts')}
3315
+ items={recommendations.related}
3316
+ className="mt-12"
3317
+ />
3318
+ )}
2900
3319
  </div>
2901
3320
  );
2902
3321
  }
2903
3322
  `,
2904
3323
  "src/app/cart/page.tsx": `'use client';
2905
3324
 
3325
+ import { useEffect, useState } from 'react';
2906
3326
  import Link from 'next/link';
3327
+ import type { CartRecommendationsResponse } from 'brainerce';
3328
+ import { getClient } from '@/lib/brainerce';
2907
3329
  import { useCart } from '@/providers/store-provider';
2908
3330
  import { CartItem } from '@/components/cart/cart-item';
2909
3331
  import { CartSummary } from '@/components/cart/cart-summary';
2910
3332
  import { CouponInput } from '@/components/cart/coupon-input';
2911
3333
  import { CartNudges } from '@/components/cart/cart-nudges';
2912
3334
  import { ReservationCountdown } from '@/components/cart/reservation-countdown';
3335
+ import { CartRecommendationSection } from '@/components/products/recommendation-section';
2913
3336
  import { LoadingSpinner } from '@/components/shared/loading-spinner';
2914
3337
  import { useTranslations } from '@/lib/translations';
2915
3338
 
@@ -2917,6 +3340,17 @@ export default function CartPage() {
2917
3340
  const { cart, cartLoading, refreshCart, itemCount } = useCart();
2918
3341
  const t = useTranslations('cart');
2919
3342
  const tc = useTranslations('common');
3343
+ const [cartRecs, setCartRecs] = useState<CartRecommendationsResponse | null>(null);
3344
+
3345
+ // Load cross-sell recommendations when cart changes
3346
+ useEffect(() => {
3347
+ if (!cart?.id || cart.items.length === 0) {
3348
+ setCartRecs(null);
3349
+ return;
3350
+ }
3351
+ const client = getClient();
3352
+ client.getCartRecommendations(cart.id, 4).then(setCartRecs).catch(() => {});
3353
+ }, [cart?.id, cart?.items.length]);
2920
3354
 
2921
3355
  if (cartLoading) {
2922
3356
  return (
@@ -3008,6 +3442,15 @@ export default function CartPage() {
3008
3442
  </div>
3009
3443
  </div>
3010
3444
  </div>
3445
+
3446
+ {/* Cross-sell recommendations */}
3447
+ {cartRecs?.recommendations && cartRecs.recommendations.length > 0 && (
3448
+ <CartRecommendationSection
3449
+ title={t('youMightAlsoNeed')}
3450
+ items={cartRecs.recommendations}
3451
+ className="mt-10"
3452
+ />
3453
+ )}
3011
3454
  </div>
3012
3455
  );
3013
3456
  }
@@ -3891,8 +4334,8 @@ export default function OrderConfirmationPage() {
3891
4334
  import { useState } from 'react';
3892
4335
  import { useRouter } from 'next/navigation';
3893
4336
  import Link from 'next/link';
3894
- import { getClient } from '@/lib/brainerce';
3895
4337
  import { useAuth } from '@/providers/store-provider';
4338
+ import { proxyLogin } from '@/lib/auth';
3896
4339
  import { LoginForm } from '@/components/auth/login-form';
3897
4340
  import { OAuthButtons } from '@/components/auth/oauth-buttons';
3898
4341
  import { useTranslations } from '@/lib/translations';
@@ -3906,15 +4349,16 @@ export default function LoginPage() {
3906
4349
  async function handleLogin(email: string, password: string) {
3907
4350
  try {
3908
4351
  setError(null);
3909
- const client = getClient();
3910
- const result = await client.loginCustomer(email, password);
4352
+ const result = await proxyLogin(email, password);
3911
4353
 
3912
4354
  if (result.requiresVerification) {
3913
- router.push(\`/verify-email?token=\${encodeURIComponent(result.token)}\`);
4355
+ // Verification token is NOT the auth JWT \u2014 safe to pass in URL
4356
+ router.push('/verify-email');
3914
4357
  return;
3915
4358
  }
3916
4359
 
3917
- auth.login(result.token);
4360
+ // Cookie was set by the proxy; refresh auth state
4361
+ await auth.login();
3918
4362
  router.push('/');
3919
4363
  } catch (err) {
3920
4364
  const message = err instanceof Error ? err.message : 'Login failed. Please try again.';
@@ -3950,8 +4394,8 @@ export default function LoginPage() {
3950
4394
  import { useState } from 'react';
3951
4395
  import { useRouter } from 'next/navigation';
3952
4396
  import Link from 'next/link';
3953
- import { getClient } from '@/lib/brainerce';
3954
4397
  import { useAuth } from '@/providers/store-provider';
4398
+ import { proxyRegister } from '@/lib/auth';
3955
4399
  import { RegisterForm } from '@/components/auth/register-form';
3956
4400
  import { OAuthButtons } from '@/components/auth/oauth-buttons';
3957
4401
  import { useTranslations } from '@/lib/translations';
@@ -3970,20 +4414,16 @@ export default function RegisterPage() {
3970
4414
  }) {
3971
4415
  try {
3972
4416
  setError(null);
3973
- const client = getClient();
3974
- const result = await client.registerCustomer({
3975
- firstName: data.firstName,
3976
- lastName: data.lastName,
3977
- email: data.email,
3978
- password: data.password,
3979
- });
4417
+ const result = await proxyRegister(data);
3980
4418
 
3981
4419
  if (result.requiresVerification) {
3982
- router.push(\`/verify-email?token=\${encodeURIComponent(result.token)}\`);
4420
+ // Cookie already set by proxy; verify-email uses it for auth
4421
+ router.push('/verify-email');
3983
4422
  return;
3984
4423
  }
3985
4424
 
3986
- auth.login(result.token);
4425
+ // Cookie was set by the proxy; refresh auth state
4426
+ await auth.login();
3987
4427
  router.push('/');
3988
4428
  } catch (err) {
3989
4429
  const message = err instanceof Error ? err.message : 'Registration failed. Please try again.';
@@ -4017,10 +4457,10 @@ export default function RegisterPage() {
4017
4457
  "src/app/verify-email/page.tsx": `'use client';
4018
4458
 
4019
4459
  import { Suspense, useState, useRef, useEffect, useCallback } from 'react';
4020
- import { useRouter, useSearchParams } from 'next/navigation';
4460
+ import { useRouter } from 'next/navigation';
4021
4461
  import Link from 'next/link';
4022
- import { getClient } from '@/lib/brainerce';
4023
4462
  import { useAuth } from '@/providers/store-provider';
4463
+ import { proxyVerifyEmail, proxyResendVerification } from '@/lib/auth';
4024
4464
  import { LoadingSpinner } from '@/components/shared/loading-spinner';
4025
4465
  import { useTranslations } from '@/lib/translations';
4026
4466
 
@@ -4029,10 +4469,8 @@ const RESEND_COOLDOWN_SECONDS = 60;
4029
4469
 
4030
4470
  function VerifyEmailContent() {
4031
4471
  const router = useRouter();
4032
- const searchParams = useSearchParams();
4033
4472
  const auth = useAuth();
4034
4473
 
4035
- const token = searchParams.get('token');
4036
4474
  const t = useTranslations('auth');
4037
4475
 
4038
4476
  const [digits, setDigits] = useState<string[]>(Array(CODE_LENGTH).fill(''));
@@ -4060,18 +4498,17 @@ function VerifyEmailContent() {
4060
4498
 
4061
4499
  const handleSubmit = useCallback(
4062
4500
  async (code: string) => {
4063
- if (!token || code.length !== CODE_LENGTH || loading) return;
4501
+ if (code.length !== CODE_LENGTH || loading) return;
4064
4502
 
4065
4503
  try {
4066
4504
  setLoading(true);
4067
4505
  setError(null);
4068
- const client = getClient();
4069
- const result = await client.verifyEmail(code, token);
4506
+ // Auth token is in httpOnly cookie \u2014 proxy adds Authorization header
4507
+ const result = await proxyVerifyEmail(code);
4070
4508
 
4071
4509
  if (result.verified) {
4072
- // token field exists on newer SDK versions
4073
- const authToken = (result as unknown as { token?: string }).token || token;
4074
- auth.login(authToken);
4510
+ // Refresh auth state (cookie already set)
4511
+ await auth.login();
4075
4512
  setSuccess('Email verified successfully! Redirecting...');
4076
4513
  setTimeout(() => router.push('/'), 1500);
4077
4514
  } else {
@@ -4085,7 +4522,7 @@ function VerifyEmailContent() {
4085
4522
  setLoading(false);
4086
4523
  }
4087
4524
  },
4088
- [token, loading, auth, router]
4525
+ [loading, auth, router]
4089
4526
  );
4090
4527
 
4091
4528
  function handleDigitChange(index: number, value: string) {
@@ -4139,13 +4576,12 @@ function VerifyEmailContent() {
4139
4576
  }
4140
4577
 
4141
4578
  async function handleResend() {
4142
- if (!token || resending || cooldown > 0) return;
4579
+ if (resending || cooldown > 0) return;
4143
4580
 
4144
4581
  try {
4145
4582
  setResending(true);
4146
4583
  setError(null);
4147
- const client = getClient();
4148
- await client.resendVerificationEmail(token);
4584
+ await proxyResendVerification();
4149
4585
  setSuccess('Verification code sent! Check your email.');
4150
4586
  setCooldown(RESEND_COOLDOWN_SECONDS);
4151
4587
  // Clear digits for fresh entry
@@ -4165,37 +4601,6 @@ function VerifyEmailContent() {
4165
4601
  handleSubmit(code);
4166
4602
  }
4167
4603
 
4168
- // No token provided
4169
- if (!token) {
4170
- return (
4171
- <div className="flex min-h-[60vh] items-center justify-center px-4 py-12">
4172
- <div className="w-full max-w-md space-y-4 text-center">
4173
- <svg
4174
- className="text-muted-foreground mx-auto h-12 w-12"
4175
- fill="none"
4176
- viewBox="0 0 24 24"
4177
- stroke="currentColor"
4178
- >
4179
- <path
4180
- strokeLinecap="round"
4181
- strokeLinejoin="round"
4182
- strokeWidth={1.5}
4183
- 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"
4184
- />
4185
- </svg>
4186
- <h1 className="text-foreground text-2xl font-bold">{t('verificationInvalid')}</h1>
4187
- <p className="text-muted-foreground text-sm">{t('verificationInvalidDesc')}</p>
4188
- <Link
4189
- href="/register"
4190
- className="bg-primary text-primary-foreground inline-flex items-center rounded px-6 py-3 font-medium transition-opacity hover:opacity-90"
4191
- >
4192
- {t('goToRegister')}
4193
- </Link>
4194
- </div>
4195
- </div>
4196
- );
4197
- }
4198
-
4199
4604
  return (
4200
4605
  <div className="flex min-h-[60vh] items-center justify-center px-4 py-12">
4201
4606
  <div className="w-full max-w-md space-y-6">
@@ -4325,8 +4730,8 @@ function OAuthCallbackContent() {
4325
4730
  const t = useTranslations('auth');
4326
4731
 
4327
4732
  const oauthSuccess = searchParams.get('oauth_success');
4328
- const token = searchParams.get('token');
4329
4733
  const oauthError = searchParams.get('oauth_error');
4734
+ // Token is no longer in URL \u2014 it was set as httpOnly cookie by /api/auth/oauth-callback
4330
4735
 
4331
4736
  useEffect(() => {
4332
4737
  // Prevent double-processing in React StrictMode
@@ -4338,13 +4743,15 @@ function OAuthCallbackContent() {
4338
4743
  return;
4339
4744
  }
4340
4745
 
4341
- if (oauthSuccess === 'true' && token) {
4342
- auth.login(token);
4343
- router.push('/');
4746
+ if (oauthSuccess === 'true') {
4747
+ // Cookie was already set by the API route; refresh auth state
4748
+ auth.login().then(() => {
4749
+ router.push('/');
4750
+ });
4344
4751
  } else {
4345
4752
  setError(t('authFailedDesc'));
4346
4753
  }
4347
- }, [oauthSuccess, token, oauthError, auth, router]);
4754
+ }, [oauthSuccess, oauthError, auth, router, t]);
4348
4755
 
4349
4756
  if (error) {
4350
4757
  return (
@@ -4511,6 +4918,354 @@ export default function AccountPage() {
4511
4918
  </div>
4512
4919
  );
4513
4920
  }
4921
+ `,
4922
+ "src/app/api/store/[...path]/route.ts": `import { NextRequest, NextResponse } from 'next/server';
4923
+ import { cookies } from 'next/headers';
4924
+
4925
+ const BACKEND_URL = (process.env.BRAINERCE_API_URL || 'https://api.brainerce.com').replace(
4926
+ /\\/$/,
4927
+ ''
4928
+ );
4929
+
4930
+ const TOKEN_COOKIE = 'brainerce_customer_token';
4931
+ const LOGGED_IN_COOKIE = 'brainerce_logged_in';
4932
+ const CSRF_HEADER = 'x-requested-with';
4933
+ const CSRF_VALUE = 'brainerce';
4934
+
4935
+ const COOKIE_MAX_AGE = 7 * 24 * 60 * 60; // 7 days
4936
+
4937
+ /** Auth endpoints whose responses contain tokens to intercept */
4938
+ const AUTH_ENDPOINTS = ['customers/login', 'customers/register', 'customers/verify-email'];
4939
+
4940
+ function isAuthEndpoint(path: string): boolean {
4941
+ return AUTH_ENDPOINTS.some((ep) => path.endsWith(ep));
4942
+ }
4943
+
4944
+ function isSecure(): boolean {
4945
+ return process.env.NODE_ENV === 'production';
4946
+ }
4947
+
4948
+ function setAuthCookies(response: NextResponse, token: string): void {
4949
+ response.cookies.set(TOKEN_COOKIE, token, {
4950
+ httpOnly: true,
4951
+ secure: isSecure(),
4952
+ sameSite: 'lax',
4953
+ path: '/',
4954
+ maxAge: COOKIE_MAX_AGE,
4955
+ });
4956
+ response.cookies.set(LOGGED_IN_COOKIE, '1', {
4957
+ httpOnly: false,
4958
+ secure: isSecure(),
4959
+ sameSite: 'lax',
4960
+ path: '/',
4961
+ maxAge: COOKIE_MAX_AGE,
4962
+ });
4963
+ }
4964
+
4965
+ function clearAuthCookies(response: NextResponse): void {
4966
+ response.cookies.delete(TOKEN_COOKIE);
4967
+ response.cookies.delete(LOGGED_IN_COOKIE);
4968
+ }
4969
+
4970
+ async function proxyRequest(
4971
+ request: NextRequest,
4972
+ params: { path: string[] }
4973
+ ): Promise<NextResponse> {
4974
+ const method = request.method;
4975
+
4976
+ // CSRF protection for mutating requests
4977
+ if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
4978
+ const csrfHeader = request.headers.get(CSRF_HEADER);
4979
+ if (csrfHeader !== CSRF_VALUE) {
4980
+ return NextResponse.json({ error: 'CSRF validation failed' }, { status: 403 });
4981
+ }
4982
+ }
4983
+
4984
+ // Build backend URL from path segments
4985
+ const pathSegments = params.path.join('/');
4986
+ const backendUrl = new URL(\`\${BACKEND_URL}/\${pathSegments}\`);
4987
+
4988
+ // Forward query parameters
4989
+ request.nextUrl.searchParams.forEach((value, key) => {
4990
+ backendUrl.searchParams.set(key, value);
4991
+ });
4992
+
4993
+ // Build headers for backend request
4994
+ const headers: Record<string, string> = {
4995
+ 'Content-Type': 'application/json',
4996
+ };
4997
+
4998
+ // Forward Origin/Referer so backend BrowserOriginGuard accepts proxied requests
4999
+ const origin = request.headers.get('origin');
5000
+ const referer = request.headers.get('referer');
5001
+ if (origin) headers['Origin'] = origin;
5002
+ if (referer) headers['Referer'] = referer;
5003
+
5004
+ // Forward SDK version header if present
5005
+ const sdkVersion = request.headers.get('x-sdk-version');
5006
+ if (sdkVersion) {
5007
+ headers['X-SDK-Version'] = sdkVersion;
5008
+ }
5009
+
5010
+ // Add auth token from httpOnly cookie
5011
+ const cookieStore = await cookies();
5012
+ const tokenCookie = cookieStore.get(TOKEN_COOKIE);
5013
+ if (tokenCookie?.value) {
5014
+ headers['Authorization'] = \`Bearer \${tokenCookie.value}\`;
5015
+ }
5016
+
5017
+ // Forward request body for non-GET requests
5018
+ let body: string | undefined;
5019
+ if (method !== 'GET' && method !== 'HEAD') {
5020
+ try {
5021
+ body = await request.text();
5022
+ } catch {
5023
+ // No body
5024
+ }
5025
+ }
5026
+
5027
+ // Proxy the request to backend
5028
+ let backendResponse: Response;
5029
+ try {
5030
+ backendResponse = await fetch(backendUrl.toString(), {
5031
+ method,
5032
+ headers,
5033
+ body,
5034
+ });
5035
+ } catch (error) {
5036
+ return NextResponse.json({ error: 'Backend service unavailable' }, { status: 502 });
5037
+ }
5038
+
5039
+ // Read response body
5040
+ const responseText = await backendResponse.text();
5041
+
5042
+ // For auth endpoints: intercept token, set cookie, strip token from response
5043
+ if (backendResponse.ok && method === 'POST' && isAuthEndpoint(pathSegments)) {
5044
+ try {
5045
+ const data = JSON.parse(responseText);
5046
+ if (data.token) {
5047
+ const token = data.token;
5048
+
5049
+ // Strip token from client response
5050
+ const { token: _stripped, ...safeData } = data;
5051
+
5052
+ const response = NextResponse.json(safeData, {
5053
+ status: backendResponse.status,
5054
+ });
5055
+ setAuthCookies(response, token);
5056
+ return response;
5057
+ }
5058
+ } catch {
5059
+ // Not JSON or no token field \u2014 pass through
5060
+ }
5061
+ }
5062
+
5063
+ // Handle 401 responses: clear auth cookies
5064
+ if (backendResponse.status === 401 && tokenCookie?.value) {
5065
+ const response = new NextResponse(responseText, {
5066
+ status: backendResponse.status,
5067
+ headers: {
5068
+ 'Content-Type': backendResponse.headers.get('Content-Type') || 'application/json',
5069
+ },
5070
+ });
5071
+ clearAuthCookies(response);
5072
+ return response;
5073
+ }
5074
+
5075
+ // Pass through response as-is
5076
+ return new NextResponse(responseText, {
5077
+ status: backendResponse.status,
5078
+ headers: {
5079
+ 'Content-Type': backendResponse.headers.get('Content-Type') || 'application/json',
5080
+ },
5081
+ });
5082
+ }
5083
+
5084
+ export async function GET(
5085
+ request: NextRequest,
5086
+ { params }: { params: Promise<{ path: string[] }> }
5087
+ ) {
5088
+ return proxyRequest(request, await params);
5089
+ }
5090
+
5091
+ export async function POST(
5092
+ request: NextRequest,
5093
+ { params }: { params: Promise<{ path: string[] }> }
5094
+ ) {
5095
+ return proxyRequest(request, await params);
5096
+ }
5097
+
5098
+ export async function PUT(
5099
+ request: NextRequest,
5100
+ { params }: { params: Promise<{ path: string[] }> }
5101
+ ) {
5102
+ return proxyRequest(request, await params);
5103
+ }
5104
+
5105
+ export async function PATCH(
5106
+ request: NextRequest,
5107
+ { params }: { params: Promise<{ path: string[] }> }
5108
+ ) {
5109
+ return proxyRequest(request, await params);
5110
+ }
5111
+
5112
+ export async function DELETE(
5113
+ request: NextRequest,
5114
+ { params }: { params: Promise<{ path: string[] }> }
5115
+ ) {
5116
+ return proxyRequest(request, await params);
5117
+ }
5118
+ `,
5119
+ "src/app/api/auth/oauth-callback/route.ts": `import { NextRequest, NextResponse } from 'next/server';
5120
+
5121
+ const TOKEN_COOKIE = 'brainerce_customer_token';
5122
+ const LOGGED_IN_COOKIE = 'brainerce_logged_in';
5123
+ const COOKIE_MAX_AGE = 7 * 24 * 60 * 60; // 7 days
5124
+
5125
+ function isSecure(): boolean {
5126
+ return process.env.NODE_ENV === 'production';
5127
+ }
5128
+
5129
+ /**
5130
+ * OAuth callback handler.
5131
+ * The backend redirects here with ?token=jwt&oauth_success=true after OAuth code exchange.
5132
+ * We set the httpOnly cookie and redirect to the client-side callback page (without the token).
5133
+ */
5134
+ export async function GET(request: NextRequest) {
5135
+ const { searchParams } = request.nextUrl;
5136
+ const token = searchParams.get('token');
5137
+ const oauthSuccess = searchParams.get('oauth_success');
5138
+ const oauthError = searchParams.get('oauth_error');
5139
+
5140
+ // Build redirect URL to client-side callback page
5141
+ const redirectUrl = new URL('/auth/callback', request.url);
5142
+
5143
+ if (oauthError) {
5144
+ redirectUrl.searchParams.set('oauth_error', oauthError);
5145
+ return NextResponse.redirect(redirectUrl);
5146
+ }
5147
+
5148
+ if (oauthSuccess === 'true' && token) {
5149
+ redirectUrl.searchParams.set('oauth_success', 'true');
5150
+
5151
+ const response = NextResponse.redirect(redirectUrl);
5152
+
5153
+ // Set httpOnly cookie with the token
5154
+ response.cookies.set(TOKEN_COOKIE, token, {
5155
+ httpOnly: true,
5156
+ secure: isSecure(),
5157
+ sameSite: 'lax',
5158
+ path: '/',
5159
+ maxAge: COOKIE_MAX_AGE,
5160
+ });
5161
+
5162
+ // Set indicator cookie (readable by client JS)
5163
+ response.cookies.set(LOGGED_IN_COOKIE, '1', {
5164
+ httpOnly: false,
5165
+ secure: isSecure(),
5166
+ sameSite: 'lax',
5167
+ path: '/',
5168
+ maxAge: COOKIE_MAX_AGE,
5169
+ });
5170
+
5171
+ return response;
5172
+ }
5173
+
5174
+ // Fallback: no token or success flag
5175
+ redirectUrl.searchParams.set('oauth_error', 'Authentication failed');
5176
+ return NextResponse.redirect(redirectUrl);
5177
+ }
5178
+ `,
5179
+ "src/app/api/auth/me/route.ts": `import { NextResponse } from 'next/server';
5180
+ import { cookies } from 'next/headers';
5181
+
5182
+ const BACKEND_URL = (process.env.BRAINERCE_API_URL || 'https://api.brainerce.com').replace(
5183
+ /\\/$/,
5184
+ ''
5185
+ );
5186
+
5187
+ const CONNECTION_ID = process.env.NEXT_PUBLIC_BRAINERCE_CONNECTION_ID || '';
5188
+
5189
+ const TOKEN_COOKIE = 'brainerce_customer_token';
5190
+ const LOGGED_IN_COOKIE = 'brainerce_logged_in';
5191
+
5192
+ /**
5193
+ * Auth status check endpoint.
5194
+ * Reads the httpOnly cookie, validates against backend, returns auth state.
5195
+ */
5196
+ export async function GET() {
5197
+ const cookieStore = await cookies();
5198
+ const tokenCookie = cookieStore.get(TOKEN_COOKIE);
5199
+
5200
+ if (!tokenCookie?.value) {
5201
+ return NextResponse.json({ isLoggedIn: false });
5202
+ }
5203
+
5204
+ try {
5205
+ // Validate token by calling backend profile endpoint
5206
+ const response = await fetch(\`\${BACKEND_URL}/api/vc/\${CONNECTION_ID}/customers/me\`, {
5207
+ headers: {
5208
+ Authorization: \`Bearer \${tokenCookie.value}\`,
5209
+ 'Content-Type': 'application/json',
5210
+ },
5211
+ });
5212
+
5213
+ if (!response.ok) {
5214
+ // Token is invalid or expired \u2014 clear cookies
5215
+ const res = NextResponse.json({ isLoggedIn: false });
5216
+ res.cookies.delete(TOKEN_COOKIE);
5217
+ res.cookies.delete(LOGGED_IN_COOKIE);
5218
+ return res;
5219
+ }
5220
+
5221
+ const customer = await response.json();
5222
+ return NextResponse.json({ isLoggedIn: true, customer });
5223
+ } catch {
5224
+ // Backend unreachable \u2014 don't clear cookies, might be temporary
5225
+ return NextResponse.json({ isLoggedIn: false, error: 'Service unavailable' }, { status: 503 });
5226
+ }
5227
+ }
5228
+ `,
5229
+ "src/app/api/auth/logout/route.ts": `import { NextResponse } from 'next/server';
5230
+
5231
+ const TOKEN_COOKIE = 'brainerce_customer_token';
5232
+ const LOGGED_IN_COOKIE = 'brainerce_logged_in';
5233
+
5234
+ /**
5235
+ * Logout endpoint. Clears auth cookies.
5236
+ */
5237
+ export async function POST() {
5238
+ const response = NextResponse.json({ success: true });
5239
+ response.cookies.delete(TOKEN_COOKIE);
5240
+ response.cookies.delete(LOGGED_IN_COOKIE);
5241
+ return response;
5242
+ }
5243
+ `,
5244
+ "src/middleware.ts": `import { NextRequest, NextResponse } from 'next/server';
5245
+
5246
+ const TOKEN_COOKIE = 'brainerce_customer_token';
5247
+
5248
+ /** Routes that require customer authentication */
5249
+ const PROTECTED_PATHS = ['/account'];
5250
+
5251
+ export function middleware(request: NextRequest) {
5252
+ const { pathname } = request.nextUrl;
5253
+ const isProtected = PROTECTED_PATHS.some((p) => pathname.startsWith(p));
5254
+
5255
+ if (isProtected) {
5256
+ const token = request.cookies.get(TOKEN_COOKIE);
5257
+ if (!token?.value) {
5258
+ const loginUrl = new URL('/login', request.url);
5259
+ return NextResponse.redirect(loginUrl);
5260
+ }
5261
+ }
5262
+
5263
+ return NextResponse.next();
5264
+ }
5265
+
5266
+ export const config = {
5267
+ matcher: ['/account/:path*'],
5268
+ };
4514
5269
  `,
4515
5270
  "src/components/products/product-card.tsx": `'use client';
4516
5271
 
@@ -4531,6 +5286,7 @@ interface ProductCardProps {
4531
5286
 
4532
5287
  export function ProductCard({ product, className }: ProductCardProps) {
4533
5288
  const t = useTranslations('common');
5289
+ const tp = useTranslations('productDetail');
4534
5290
  const { price, originalPrice, isOnSale } = getProductPriceInfo(product);
4535
5291
  const mainImage = product.images?.[0];
4536
5292
  const imageUrl = mainImage?.url || null;
@@ -4575,6 +5331,11 @@ export function ProductCard({ product, className }: ProductCardProps) {
4575
5331
  </span>
4576
5332
  )}
4577
5333
  <DiscountBadge discount={product.discount} />
5334
+ {product.isDownloadable && (
5335
+ <span className="bg-primary text-primary-foreground rounded px-2 py-1 text-xs font-bold">
5336
+ {tp('digitalProduct')}
5337
+ </span>
5338
+ )}
4578
5339
  </div>
4579
5340
  </div>
4580
5341
 
@@ -5842,8 +6603,11 @@ declare global {
5842
6603
  }
5843
6604
 
5844
6605
  // Payment SDK script URLs \u2014 resolved per provider
6606
+ // SRI hashes must be regenerated when the provider updates their SDK
5845
6607
  const PAYMENT_SDK_URL = 'https://cdn.meshulam.co.il/sdk/gs.min.js';
6608
+ const PAYMENT_SDK_SRI = 'sha384-e1OYzZERZLgbR8Zw1I4Ww/J7qXGyEsZb7LF0z5W/sbnTsFwtxop7sKZsLGNp6CB1';
5846
6609
  const APPLE_PAY_SDK_URL = 'https://meshulam.co.il/_media/js/apple_pay_sdk/sdk.min.js';
6610
+ const APPLE_PAY_SDK_SRI = 'sha384-CG67HXmWBeyuRR/jqFsYr6dGEMFyuZw3/hRmcyACJRYHGb/1ebEkQZyzdJXDc9/u';
5847
6611
 
5848
6612
  interface PaymentStepProps {
5849
6613
  checkoutId: string;
@@ -5854,7 +6618,7 @@ interface PaymentStepProps {
5854
6618
  * Load a script tag if not already present in the DOM.
5855
6619
  * Resolves when the script loads (or immediately if already present).
5856
6620
  */
5857
- function loadScript(src: string, optional = false): Promise<void> {
6621
+ function loadScript(src: string, optional = false, integrity?: string): Promise<void> {
5858
6622
  return new Promise((resolve) => {
5859
6623
  if (document.querySelector(\`script[src="\${src}"]\`)) {
5860
6624
  resolve();
@@ -5863,6 +6627,10 @@ function loadScript(src: string, optional = false): Promise<void> {
5863
6627
  const script = document.createElement('script');
5864
6628
  script.src = src;
5865
6629
  script.async = true;
6630
+ if (integrity) {
6631
+ script.integrity = integrity;
6632
+ script.crossOrigin = 'anonymous';
6633
+ }
5866
6634
  script.onload = () => resolve();
5867
6635
  script.onerror = () => {
5868
6636
  if (optional) {
@@ -5955,10 +6723,10 @@ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
5955
6723
 
5956
6724
  async function loadSdkScripts() {
5957
6725
  // Load Apple Pay SDK (optional)
5958
- await loadScript(APPLE_PAY_SDK_URL, true);
6726
+ await loadScript(APPLE_PAY_SDK_URL, true, APPLE_PAY_SDK_SRI);
5959
6727
 
5960
6728
  // Load payment SDK script
5961
- await loadScript(PAYMENT_SDK_URL);
6729
+ await loadScript(PAYMENT_SDK_URL, false, PAYMENT_SDK_SRI);
5962
6730
 
5963
6731
  // Wait for SDK global to be set by the script
5964
6732
  const available = await waitForPaymentSdkGlobal();
@@ -6621,7 +7389,7 @@ export function OAuthButtons({ className }: OAuthButtonsProps) {
6621
7389
  try {
6622
7390
  setRedirecting(provider);
6623
7391
  const client = getClient();
6624
- const redirectUrl = window.location.origin + '/auth/callback';
7392
+ const redirectUrl = window.location.origin + '/api/auth/oauth-callback';
6625
7393
  const result = await client.getOAuthAuthorizeUrl(provider, { redirectUrl });
6626
7394
  window.location.href = result.authorizationUrl;
6627
7395
  } catch (err) {
@@ -6903,10 +7671,11 @@ export function ProfileSection({ profile, onProfileUpdate, className }: ProfileS
6903
7671
  `,
6904
7672
  "src/components/account/order-history.tsx": `'use client';
6905
7673
 
6906
- import { useState } from 'react';
7674
+ import { useState, useEffect } from 'react';
6907
7675
  import Image from 'next/image';
6908
- import type { Order, OrderStatus } from 'brainerce';
7676
+ import type { Order, OrderStatus, OrderDownloadLink } from 'brainerce';
6909
7677
  import { formatPrice } from 'brainerce';
7678
+ import { getClient } from '@/lib/brainerce';
6910
7679
  import { useTranslations } from '@/lib/translations';
6911
7680
  import { cn } from '@/lib/utils';
6912
7681
 
@@ -7090,6 +7859,11 @@ function OrderCard({ order }: { order: Order }) {
7090
7859
  </div>
7091
7860
  ))}
7092
7861
 
7862
+ {/* Downloads section */}
7863
+ {order.hasDownloads && (
7864
+ <OrderDownloads orderId={order.id} />
7865
+ )}
7866
+
7093
7867
  <OrderFinancialSummary order={order} currency={currency} />
7094
7868
  </div>
7095
7869
  )}
@@ -7097,6 +7871,71 @@ function OrderCard({ order }: { order: Order }) {
7097
7871
  );
7098
7872
  }
7099
7873
 
7874
+ function OrderDownloads({ orderId }: { orderId: string }) {
7875
+ const t = useTranslations('account');
7876
+ const [downloads, setDownloads] = useState<OrderDownloadLink[] | null>(null);
7877
+ const [loading, setLoading] = useState(true);
7878
+
7879
+ useEffect(() => {
7880
+ let cancelled = false;
7881
+ async function fetch() {
7882
+ try {
7883
+ const client = getClient();
7884
+ const links = await client.getOrderDownloads(orderId);
7885
+ if (!cancelled) setDownloads(links);
7886
+ } catch {
7887
+ if (!cancelled) setDownloads([]);
7888
+ } finally {
7889
+ if (!cancelled) setLoading(false);
7890
+ }
7891
+ }
7892
+ fetch();
7893
+ return () => { cancelled = true; };
7894
+ }, [orderId]);
7895
+
7896
+ if (loading) {
7897
+ return (
7898
+ <div className="border-border border-t pt-2">
7899
+ <p className="text-muted-foreground animate-pulse text-xs">{t('downloads')}...</p>
7900
+ </div>
7901
+ );
7902
+ }
7903
+
7904
+ if (!downloads || downloads.length === 0) return null;
7905
+
7906
+ return (
7907
+ <div className="border-border space-y-2 border-t pt-2">
7908
+ <p className="text-foreground text-sm font-medium">{t('downloads')}</p>
7909
+ {downloads.map((link, idx) => (
7910
+ <div key={idx} className="flex items-center gap-3">
7911
+ <div className="min-w-0 flex-1">
7912
+ <p className="text-foreground truncate text-sm">{link.fileName}</p>
7913
+ <p className="text-muted-foreground text-xs">
7914
+ {link.productName}
7915
+ {' \xB7 '}
7916
+ {link.downloadLimit != null
7917
+ ? t('downloadsRemaining', { used: link.downloadsUsed, limit: link.downloadLimit })
7918
+ : t('unlimitedDownloads')}
7919
+ {' \xB7 '}
7920
+ {link.expiresAt
7921
+ ? t('expiresAt', { date: new Date(link.expiresAt).toLocaleDateString() })
7922
+ : t('noExpiry')}
7923
+ </p>
7924
+ </div>
7925
+ <a
7926
+ href={link.downloadUrl}
7927
+ target="_blank"
7928
+ rel="noopener noreferrer"
7929
+ className="bg-primary text-primary-foreground flex-shrink-0 rounded px-3 py-1 text-xs font-medium hover:opacity-90"
7930
+ >
7931
+ {t('downloadFile')}
7932
+ </a>
7933
+ </div>
7934
+ ))}
7935
+ </div>
7936
+ );
7937
+ }
7938
+
7100
7939
  function OrderFinancialSummary({ order, currency }: { order: Order; currency: string }) {
7101
7940
  const tc = useTranslations('common');
7102
7941
  const totalAmount = order.totalAmount || order.total || '0';
@@ -7978,7 +8817,7 @@ var DETAILED = `# Required Pages \u2014 Detailed Guide
7978
8817
  - Stock badge
7979
8818
  - Discount badge (\`getProductDiscountBadge()\`)
7980
8819
  - Add to cart button (disabled when \`!canPurchase\`)
7981
- - Product recommendations (\`getProductRecommendations()\`)
8820
+ - Product recommendations (from \`product.recommendations\` \u2014 embedded in product response)
7982
8821
  - Use \`client.getProductBySlug(slug)\`
7983
8822
 
7984
8823
  ## 4. Cart (\`/cart\`)
@@ -8256,6 +9095,39 @@ my-store/
8256
9095
  | \`app/auth/callback/page.tsx\` | Extracts OAuth token from URL params, stores in localStorage |
8257
9096
  | \`app/account/page.tsx\` | Protected page: getMyProfile() + getMyOrders() |
8258
9097
  | \`components/Header.tsx\` | Site header with nav, cart count badge, search autocomplete |
9098
+
9099
+ ## Content Security Policy (CSP)
9100
+
9101
+ Payment SDKs load iframes and scripts from external domains. Add these CSP headers in \`next.config.js\`:
9102
+
9103
+ \`\`\`js
9104
+ // next.config.js
9105
+ module.exports = {
9106
+ async headers() {
9107
+ return [{
9108
+ source: '/(.*)',
9109
+ headers: [{
9110
+ key: 'Content-Security-Policy',
9111
+ value: [
9112
+ "default-src 'self'",
9113
+ "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",
9114
+ "style-src 'self' 'unsafe-inline'",
9115
+ "img-src 'self' data: blob: https:",
9116
+ "font-src 'self' data:",
9117
+ "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",
9118
+ "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",
9119
+ "worker-src 'self' blob:",
9120
+ ].join('; '),
9121
+ }],
9122
+ }];
9123
+ },
9124
+ };
9125
+ \`\`\`
9126
+
9127
+ **Required domains by provider:**
9128
+ - **Grow**: \`*.meshulam.co.il\`, \`grow.link\`, \`*.grow.link\`, \`*.grow.security\`, \`*.creditguard.co.il\`
9129
+ - **Stripe**: \`js.stripe.com\`, \`hooks.stripe.com\`, \`*.stripe.com\`
9130
+ - **Google Pay**: \`pay.google.com\`, \`google.com\`
8259
9131
  `;
8260
9132
 
8261
9133
  // src/resources/project-template.ts