@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.js CHANGED
@@ -728,20 +728,25 @@ Display: banners in header, badges on product cards (strikethrough + discounted
728
728
  function getRecommendationsSection() {
729
729
  return `## Product Recommendations (Cross-Sells & Upsells)
730
730
 
731
+ Recommendations come **embedded in the product response** \u2014 no extra API call needed for product pages.
732
+
731
733
  \`\`\`typescript
732
734
  import type { ProductRecommendation, ProductRecommendationsResponse, CartRecommendationsResponse } from 'brainerce';
733
735
 
734
- // Product page \u2014 cross-sells, upsells, related
735
- const recs: ProductRecommendationsResponse = await client.getProductRecommendations(productId);
736
- // recs.crossSells, recs.upsells, recs.related
736
+ // Product page \u2014 recommendations are embedded in the product response
737
+ const product = await client.getProductBySlug('some-slug');
738
+ const recs = (product as any).recommendations as ProductRecommendationsResponse | undefined;
739
+ // recs?.upsells \u2014 premium alternatives (show on product page)
740
+ // recs?.related \u2014 similar products (show at bottom of product page)
741
+ // recs?.crossSells \u2014 complementary products (typically used on cart page)
737
742
 
738
- // Cart page \u2014 cross-sell suggestions for cart items
743
+ // Cart page \u2014 cross-sell suggestions for cart items (separate call)
739
744
  const cartRecs: CartRecommendationsResponse = await client.getCartRecommendations(cartId, 4);
740
- // cartRecs.recommendations \u2014 deduplicated cross-sells
745
+ // cartRecs.recommendations \u2014 deduplicated cross-sells (excludes items already in cart)
741
746
  \`\`\`
742
747
 
743
748
  ProductRecommendation: \`id\`, \`name\`, \`slug\`, \`basePrice\`, \`salePrice\`, \`images\`, \`type\`, \`inventory\`, \`relationType\`.
744
- Display "You might also like" on product pages, "Recommended for you" in cart.`;
749
+ Display upsells + related on product pages, cross-sells on cart page.`;
745
750
  }
746
751
  function getInventorySection() {
747
752
  return `## Inventory, Stock Display & Reservation Countdown
@@ -1910,39 +1915,24 @@ var EMBEDDED_TEMPLATES = {
1910
1915
  "src/lib/brainerce.ts.ejs": `import { BrainerceClient } from 'brainerce';
1911
1916
 
1912
1917
  const CONNECTION_ID = process.env.NEXT_PUBLIC_BRAINERCE_CONNECTION_ID || 'vc_YOUR_CONNECTION_ID';
1913
- const API_URL = process.env.NEXT_PUBLIC_BRAINERCE_API_URL || 'https://api.brainerce.com';
1914
1918
 
1915
- // Singleton SDK client
1919
+ // Singleton SDK client \u2014 routes through same-origin BFF proxy for httpOnly cookie auth
1916
1920
  let clientInstance: BrainerceClient | null = null;
1917
1921
 
1918
1922
  export function getClient(): BrainerceClient {
1919
1923
  if (!clientInstance) {
1920
1924
  clientInstance = new BrainerceClient({
1921
1925
  connectionId: CONNECTION_ID,
1922
- baseUrl: API_URL,
1926
+ baseUrl: '/api/store', // same-origin proxy handles auth via httpOnly cookie
1927
+ proxyMode: true, // skip client-side token checks; proxy adds Authorization header
1923
1928
  });
1924
1929
  }
1925
1930
  return clientInstance;
1926
1931
  }
1927
1932
 
1928
- // Auth token helpers
1929
- const TOKEN_KEY = 'brainerce_customer_token';
1933
+ // Cart ID helpers (not a security token \u2014 safe in localStorage)
1930
1934
  const CART_ID_KEY = 'brainerce_cart_id';
1931
1935
 
1932
- export function getStoredToken(): string | null {
1933
- if (typeof window === 'undefined') return null;
1934
- return localStorage.getItem(TOKEN_KEY);
1935
- }
1936
-
1937
- export function setStoredToken(token: string | null): void {
1938
- if (typeof window === 'undefined') return;
1939
- if (token) {
1940
- localStorage.setItem(TOKEN_KEY, token);
1941
- } else {
1942
- localStorage.removeItem(TOKEN_KEY);
1943
- }
1944
- }
1945
-
1946
1936
  export function getStoredCartId(): string | null {
1947
1937
  if (typeof window === 'undefined') return null;
1948
1938
  return localStorage.getItem(CART_ID_KEY);
@@ -1957,14 +1947,158 @@ export function setStoredCartId(cartId: string | null): void {
1957
1947
  }
1958
1948
  }
1959
1949
 
1960
- // Initialize client with stored auth
1950
+ // Initialize client (no token hydration \u2014 auth handled by httpOnly cookie)
1961
1951
  export function initClient(): BrainerceClient {
1962
- const client = getClient();
1963
- const token = getStoredToken();
1964
- if (token) {
1965
- client.setCustomerToken(token);
1952
+ return getClient();
1953
+ }
1954
+ `,
1955
+ "src/lib/auth.ts": `/**
1956
+ * Client-side auth helpers that call the BFF proxy API routes.
1957
+ * All mutating requests include the CSRF header.
1958
+ * The token is managed server-side via httpOnly cookies \u2014 never exposed to JS.
1959
+ */
1960
+
1961
+ const CONNECTION_ID = process.env.NEXT_PUBLIC_BRAINERCE_CONNECTION_ID || '';
1962
+
1963
+ const CSRF_HEADERS: Record<string, string> = {
1964
+ 'Content-Type': 'application/json',
1965
+ 'X-Requested-With': 'brainerce',
1966
+ };
1967
+
1968
+ interface LoginResult {
1969
+ customer: {
1970
+ id: string;
1971
+ email: string;
1972
+ firstName?: string;
1973
+ lastName?: string;
1974
+ emailVerified: boolean;
1975
+ };
1976
+ expiresAt: string;
1977
+ requiresVerification?: boolean;
1978
+ }
1979
+
1980
+ interface RegisterResult {
1981
+ customer: {
1982
+ id: string;
1983
+ email: string;
1984
+ firstName?: string;
1985
+ lastName?: string;
1986
+ emailVerified: boolean;
1987
+ };
1988
+ expiresAt: string;
1989
+ requiresVerification?: boolean;
1990
+ }
1991
+
1992
+ interface AuthStatus {
1993
+ isLoggedIn: boolean;
1994
+ customer?: {
1995
+ id: string;
1996
+ email: string;
1997
+ firstName?: string;
1998
+ lastName?: string;
1999
+ phone?: string;
2000
+ emailVerified: boolean;
2001
+ };
2002
+ error?: string;
2003
+ }
2004
+
2005
+ interface VerifyEmailResult {
2006
+ verified: boolean;
2007
+ message?: string;
2008
+ }
2009
+
2010
+ async function handleResponse<T>(response: Response): Promise<T> {
2011
+ const data = await response.json();
2012
+ if (!response.ok) {
2013
+ throw new Error(data.message || data.error || \`Request failed (\${response.status})\`);
1966
2014
  }
1967
- return client;
2015
+ return data as T;
2016
+ }
2017
+
2018
+ /**
2019
+ * Login via BFF proxy. The proxy sets the httpOnly cookie on success.
2020
+ */
2021
+ export async function proxyLogin(email: string, password: string): Promise<LoginResult> {
2022
+ const response = await fetch(\`/api/store/api/vc/\${CONNECTION_ID}/customers/login\`, {
2023
+ method: 'POST',
2024
+ headers: CSRF_HEADERS,
2025
+ body: JSON.stringify({ email, password }),
2026
+ });
2027
+ return handleResponse<LoginResult>(response);
2028
+ }
2029
+
2030
+ /**
2031
+ * Register via BFF proxy. The proxy sets the httpOnly cookie on success.
2032
+ */
2033
+ export async function proxyRegister(data: {
2034
+ firstName: string;
2035
+ lastName: string;
2036
+ email: string;
2037
+ password: string;
2038
+ }): Promise<RegisterResult> {
2039
+ const response = await fetch(\`/api/store/api/vc/\${CONNECTION_ID}/customers/register\`, {
2040
+ method: 'POST',
2041
+ headers: CSRF_HEADERS,
2042
+ body: JSON.stringify(data),
2043
+ });
2044
+ return handleResponse<RegisterResult>(response);
2045
+ }
2046
+
2047
+ /**
2048
+ * Check auth status. Reads httpOnly cookie server-side and validates with backend.
2049
+ */
2050
+ export async function checkAuthStatus(): Promise<AuthStatus> {
2051
+ const response = await fetch('/api/auth/me');
2052
+ return response.json();
2053
+ }
2054
+
2055
+ /**
2056
+ * Logout. Clears httpOnly auth cookies server-side.
2057
+ */
2058
+ export async function proxyLogout(): Promise<void> {
2059
+ await fetch('/api/auth/logout', {
2060
+ method: 'POST',
2061
+ headers: { 'X-Requested-With': 'brainerce' },
2062
+ });
2063
+ }
2064
+
2065
+ /**
2066
+ * Verify email via BFF proxy. The auth token is in the httpOnly cookie (set during login/register).
2067
+ * The proxy adds the Authorization header automatically.
2068
+ */
2069
+ export async function proxyVerifyEmail(code: string): Promise<VerifyEmailResult> {
2070
+ const response = await fetch(\`/api/store/api/vc/\${CONNECTION_ID}/customers/verify-email\`, {
2071
+ method: 'POST',
2072
+ headers: CSRF_HEADERS,
2073
+ body: JSON.stringify({ code }),
2074
+ });
2075
+ return handleResponse<VerifyEmailResult>(response);
2076
+ }
2077
+
2078
+ /**
2079
+ * Resend verification email via BFF proxy.
2080
+ * Uses the auth token from the httpOnly cookie.
2081
+ */
2082
+ export async function proxyResendVerification(): Promise<{ message: string }> {
2083
+ const response = await fetch(\`/api/store/api/vc/\${CONNECTION_ID}/customers/resend-verification\`, {
2084
+ method: 'POST',
2085
+ headers: CSRF_HEADERS,
2086
+ });
2087
+ return handleResponse<{ message: string }>(response);
2088
+ }
2089
+
2090
+ /**
2091
+ * Reset password via BFF proxy.
2092
+ * The reset token is in an httpOnly cookie (set by /api/auth/reset-callback when the user
2093
+ * clicked the email link). The proxy reads it server-side \u2014 the token never reaches client JS.
2094
+ */
2095
+ export async function proxyResetPassword(newPassword: string): Promise<{ message: string }> {
2096
+ const response = await fetch('/api/auth/reset-password', {
2097
+ method: 'POST',
2098
+ headers: CSRF_HEADERS,
2099
+ body: JSON.stringify({ newPassword }),
2100
+ });
2101
+ return handleResponse<{ message: string }>(response);
1968
2102
  }
1969
2103
  `,
1970
2104
  "src/lib/utils.ts": `import { clsx, type ClassValue } from 'clsx';
@@ -1977,9 +2111,10 @@ export function cn(...inputs: ClassValue[]) {
1977
2111
  "src/providers/store-provider.tsx.ejs": `'use client';
1978
2112
 
1979
2113
  import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
1980
- import type { StoreInfo, Cart } from 'brainerce';
2114
+ import type { StoreInfo, Cart, CustomerProfile } from 'brainerce';
1981
2115
  import { getCartTotals } from 'brainerce';
1982
- import { getClient, initClient, getStoredToken, setStoredToken, setStoredCartId } from '@/lib/brainerce';
2116
+ import { getClient, initClient, setStoredCartId } from '@/lib/brainerce';
2117
+ import { checkAuthStatus, proxyLogout } from '@/lib/auth';
1983
2118
 
1984
2119
  // ---- Store Info Context ----
1985
2120
  interface StoreInfoContextValue {
@@ -2000,17 +2135,17 @@ export function useStoreInfo() {
2000
2135
  interface AuthContextValue {
2001
2136
  isLoggedIn: boolean;
2002
2137
  authLoading: boolean;
2003
- token: string | null;
2004
- login: (token: string) => void;
2005
- logout: () => void;
2138
+ customer: CustomerProfile | null;
2139
+ login: () => Promise<void>;
2140
+ logout: () => Promise<void>;
2006
2141
  }
2007
2142
 
2008
2143
  const AuthContext = createContext<AuthContextValue>({
2009
2144
  isLoggedIn: false,
2010
2145
  authLoading: true,
2011
- token: null,
2012
- login: () => {},
2013
- logout: () => {},
2146
+ customer: null,
2147
+ login: async () => {},
2148
+ logout: async () => {},
2014
2149
  });
2015
2150
 
2016
2151
  export function useAuth() {
@@ -2042,26 +2177,46 @@ export function useCart() {
2042
2177
  export function StoreProvider({ children }: { children: React.ReactNode }) {
2043
2178
  const [storeInfo, setStoreInfo] = useState<StoreInfo | null>(null);
2044
2179
  const [storeLoading, setStoreLoading] = useState(true);
2045
- const [token, setToken] = useState<string | null>(null);
2180
+ const [isLoggedIn, setIsLoggedIn] = useState(false);
2181
+ const [customer, setCustomer] = useState<CustomerProfile | null>(null);
2046
2182
  const [authLoading, setAuthLoading] = useState(true);
2047
2183
  const [cart, setCart] = useState<Cart | null>(null);
2048
2184
  const [cartLoading, setCartLoading] = useState(true);
2049
2185
 
2050
- // Initialize client and auth
2186
+ // Check auth status via httpOnly cookie (server-side validation)
2187
+ const refreshAuth = useCallback(async () => {
2188
+ try {
2189
+ const status = await checkAuthStatus();
2190
+ setIsLoggedIn(status.isLoggedIn);
2191
+ setCustomer(status.isLoggedIn ? (status.customer as CustomerProfile) : null);
2192
+ } catch {
2193
+ setIsLoggedIn(false);
2194
+ setCustomer(null);
2195
+ } finally {
2196
+ setAuthLoading(false);
2197
+ }
2198
+ }, []);
2199
+
2200
+ // Initialize client, check auth, and fetch store info
2051
2201
  useEffect(() => {
2052
2202
  const client = initClient();
2053
- const stored = getStoredToken();
2054
- if (stored) {
2055
- setToken(stored);
2203
+
2204
+ // Optimistic check: if brainerce_logged_in cookie exists, assume logged in
2205
+ // while we validate the actual token server-side
2206
+ if (typeof document !== 'undefined' && document.cookie.includes('brainerce_logged_in=1')) {
2207
+ setIsLoggedIn(true);
2056
2208
  }
2057
- setAuthLoading(false);
2058
2209
 
2210
+ // Validate auth token server-side
2211
+ refreshAuth();
2212
+
2213
+ // Fetch store info (public, no auth needed)
2059
2214
  client
2060
2215
  .getStoreInfo()
2061
2216
  .then(setStoreInfo)
2062
2217
  .catch(console.error)
2063
2218
  .finally(() => setStoreLoading(false));
2064
- }, []);
2219
+ }, [refreshAuth]);
2065
2220
 
2066
2221
  // Cart management
2067
2222
  const refreshCart = useCallback(async () => {
@@ -2084,24 +2239,26 @@ export function StoreProvider({ children }: { children: React.ReactNode }) {
2084
2239
 
2085
2240
  useEffect(() => {
2086
2241
  refreshCart();
2087
- }, [refreshCart, token]);
2242
+ }, [refreshCart, isLoggedIn]);
2088
2243
 
2089
- const login = useCallback((newToken: string) => {
2090
- const client = getClient();
2091
- client.setCustomerToken(newToken);
2092
- setStoredToken(newToken);
2093
- setToken(newToken);
2244
+ // Called after successful login (cookie already set by proxy)
2245
+ const login = useCallback(async () => {
2246
+ // Refresh auth state from server (reads httpOnly cookie)
2247
+ await refreshAuth();
2094
2248
 
2095
2249
  // Merge guest session cart into customer cart
2250
+ const client = getClient();
2096
2251
  client.syncCartOnLogin().catch(console.error);
2097
- }, []);
2252
+ }, [refreshAuth]);
2253
+
2254
+ const logout = useCallback(async () => {
2255
+ // Clear httpOnly cookie server-side
2256
+ await proxyLogout();
2098
2257
 
2099
- const logout = useCallback(() => {
2100
2258
  const client = getClient();
2101
- client.clearCustomerToken();
2102
2259
  client.onLogout();
2103
- setStoredToken(null);
2104
- setToken(null);
2260
+ setIsLoggedIn(false);
2261
+ setCustomer(null);
2105
2262
  setCart(null);
2106
2263
  refreshCart();
2107
2264
  }, [refreshCart]);
@@ -2114,7 +2271,7 @@ export function StoreProvider({ children }: { children: React.ReactNode }) {
2114
2271
 
2115
2272
  return (
2116
2273
  <StoreInfoContext.Provider value={{ storeInfo, loading: storeLoading }}>
2117
- <AuthContext.Provider value={{ isLoggedIn: !!token, authLoading, token, login, logout }}>
2274
+ <AuthContext.Provider value={{ isLoggedIn, authLoading, customer, login, logout }}>
2118
2275
  <CartContext.Provider
2119
2276
  value={{ cart, cartLoading, refreshCart, itemCount, totals }}
2120
2277
  >
@@ -2263,7 +2420,7 @@ export default function RootLayout({
2263
2420
  `,
2264
2421
  "src/app/products/page.tsx": `'use client';
2265
2422
 
2266
- import { Suspense, useEffect, useState, useCallback } from 'react';
2423
+ import { Suspense, useEffect, useState, useCallback, useRef } from 'react';
2267
2424
  import { useSearchParams, useRouter } from 'next/navigation';
2268
2425
  import type { Product } from 'brainerce';
2269
2426
  import type { ProductQueryParams } from 'brainerce';
@@ -2289,9 +2446,198 @@ const sortOptions: SortOption[] = [
2289
2446
  { labelKey: 'sortPriceHigh', sortBy: 'price', sortOrder: 'desc' },
2290
2447
  ];
2291
2448
 
2292
- interface CategoryFilter {
2449
+ interface CategoryNode {
2293
2450
  id: string;
2294
2451
  name: string;
2452
+ parentId?: string | null;
2453
+ children: CategoryNode[];
2454
+ }
2455
+
2456
+ /** Collect all descendant IDs (including self) */
2457
+ function getAllDescendantIds(node: CategoryNode): string[] {
2458
+ const ids = [node.id];
2459
+ for (const child of node.children) {
2460
+ ids.push(...getAllDescendantIds(child));
2461
+ }
2462
+ return ids;
2463
+ }
2464
+
2465
+ /** Check if a category or any of its descendants matches the selected ID */
2466
+ function isActiveInTree(node: CategoryNode, selectedId: string): boolean {
2467
+ if (node.id === selectedId) return true;
2468
+ return node.children.some((child) => isActiveInTree(child, selectedId));
2469
+ }
2470
+
2471
+ /** Chevron down SVG */
2472
+ function ChevronDown({ className }: { className?: string }) {
2473
+ return (
2474
+ <svg
2475
+ className={className}
2476
+ width="12"
2477
+ height="12"
2478
+ viewBox="0 0 12 12"
2479
+ fill="none"
2480
+ stroke="currentColor"
2481
+ strokeWidth="2"
2482
+ strokeLinecap="round"
2483
+ strokeLinejoin="round"
2484
+ >
2485
+ <path d="M3 4.5L6 7.5L9 4.5" />
2486
+ </svg>
2487
+ );
2488
+ }
2489
+
2490
+ /** Recursive dropdown items for nested categories */
2491
+ function CategoryDropdownItems({
2492
+ children,
2493
+ depth,
2494
+ selectedId,
2495
+ onSelect,
2496
+ }: {
2497
+ children: CategoryNode[];
2498
+ depth: number;
2499
+ selectedId: string;
2500
+ onSelect: (id: string) => void;
2501
+ }) {
2502
+ return (
2503
+ <>
2504
+ {children.map((child) => (
2505
+ <div key={child.id}>
2506
+ <button
2507
+ onClick={() => onSelect(child.id)}
2508
+ className={cn(
2509
+ 'w-full text-start px-4 py-2 text-sm transition-colors hover:bg-muted',
2510
+ selectedId === child.id && 'bg-primary/10 text-primary font-medium'
2511
+ )}
2512
+ style={{ paddingInlineStart: \`\${(depth + 1) * 16}px\` }}
2513
+ >
2514
+ {child.name}
2515
+ </button>
2516
+ {child.children.length > 0 && (
2517
+ <CategoryDropdownItems
2518
+ children={child.children}
2519
+ depth={depth + 1}
2520
+ selectedId={selectedId}
2521
+ onSelect={onSelect}
2522
+ />
2523
+ )}
2524
+ </div>
2525
+ ))}
2526
+ </>
2527
+ );
2528
+ }
2529
+
2530
+ /** Category chip with dropdown for subcategories */
2531
+ function CategoryChip({
2532
+ category,
2533
+ selectedId,
2534
+ onSelect,
2535
+ tc,
2536
+ }: {
2537
+ category: CategoryNode;
2538
+ selectedId: string;
2539
+ onSelect: (id: string) => void;
2540
+ tc: (key: string) => string;
2541
+ }) {
2542
+ const [open, setOpen] = useState(false);
2543
+ const ref = useRef<HTMLDivElement>(null);
2544
+ const hasChildren = category.children.length > 0;
2545
+ const isActive = isActiveInTree(category, selectedId);
2546
+
2547
+ // Find the display name for the selected subcategory
2548
+ function findName(nodes: CategoryNode[], id: string): string | null {
2549
+ for (const n of nodes) {
2550
+ if (n.id === id) return n.name;
2551
+ const found = findName(n.children, id);
2552
+ if (found) return found;
2553
+ }
2554
+ return null;
2555
+ }
2556
+
2557
+ const selectedChildName =
2558
+ isActive && selectedId !== category.id ? findName(category.children, selectedId) : null;
2559
+
2560
+ // Close dropdown on outside click
2561
+ useEffect(() => {
2562
+ if (!open) return;
2563
+ function handleClick(e: MouseEvent) {
2564
+ if (ref.current && !ref.current.contains(e.target as Node)) {
2565
+ setOpen(false);
2566
+ }
2567
+ }
2568
+ document.addEventListener('mousedown', handleClick);
2569
+ return () => document.removeEventListener('mousedown', handleClick);
2570
+ }, [open]);
2571
+
2572
+ if (!hasChildren) {
2573
+ return (
2574
+ <button
2575
+ onClick={() => onSelect(category.id)}
2576
+ className={cn(
2577
+ 'rounded-full border px-3 py-1.5 text-sm transition-colors',
2578
+ selectedId === category.id
2579
+ ? 'bg-primary text-primary-foreground border-primary'
2580
+ : 'border-border text-muted-foreground hover:border-primary hover:text-foreground'
2581
+ )}
2582
+ >
2583
+ {category.name}
2584
+ </button>
2585
+ );
2586
+ }
2587
+
2588
+ return (
2589
+ <div ref={ref} className="relative">
2590
+ <button
2591
+ onClick={() => setOpen((prev) => !prev)}
2592
+ className={cn(
2593
+ 'inline-flex items-center gap-1 rounded-full border px-3 py-1.5 text-sm transition-colors',
2594
+ isActive
2595
+ ? 'bg-primary text-primary-foreground border-primary'
2596
+ : 'border-border text-muted-foreground hover:border-primary hover:text-foreground'
2597
+ )}
2598
+ >
2599
+ {category.name}
2600
+ {selectedChildName && (
2601
+ <span className="opacity-80">
2602
+ {'\xB7'} {selectedChildName}
2603
+ </span>
2604
+ )}
2605
+ <ChevronDown
2606
+ className={cn('transition-transform ms-0.5', open && 'rotate-180')}
2607
+ />
2608
+ </button>
2609
+
2610
+ {open && (
2611
+ <div className="bg-background border-border absolute start-0 top-full z-50 mt-1 min-w-[180px] overflow-hidden rounded-lg border shadow-lg">
2612
+ {/* "All in [category]" option */}
2613
+ <button
2614
+ onClick={() => {
2615
+ onSelect(category.id);
2616
+ setOpen(false);
2617
+ }}
2618
+ className={cn(
2619
+ 'w-full text-start px-4 py-2 text-sm font-medium transition-colors hover:bg-muted',
2620
+ selectedId === category.id && 'bg-primary/10 text-primary'
2621
+ )}
2622
+ >
2623
+ {tc('all')} {category.name}
2624
+ </button>
2625
+ <div className="bg-border mx-2 h-px" />
2626
+ {/* Recursive children */}
2627
+ <div
2628
+ onClick={() => setOpen(false)}
2629
+ >
2630
+ <CategoryDropdownItems
2631
+ children={category.children}
2632
+ depth={0}
2633
+ selectedId={selectedId}
2634
+ onSelect={onSelect}
2635
+ />
2636
+ </div>
2637
+ </div>
2638
+ )}
2639
+ </div>
2640
+ );
2295
2641
  }
2296
2642
 
2297
2643
  function ProductsContent() {
@@ -2310,18 +2656,18 @@ function ProductsContent() {
2310
2656
  const [page, setPage] = useState(1);
2311
2657
  const [totalPages, setTotalPages] = useState(1);
2312
2658
  const [total, setTotal] = useState(0);
2313
- const [categories, setCategories] = useState<CategoryFilter[]>([]);
2659
+ const [categories, setCategories] = useState<CategoryNode[]>([]);
2314
2660
 
2315
2661
  const sortIndex = parseInt(sortParam, 10) || 0;
2316
2662
  const currentSort = sortOptions[sortIndex] || sortOptions[0];
2317
2663
 
2318
- // Load categories
2664
+ // Load categories (keep tree structure)
2319
2665
  useEffect(() => {
2320
2666
  async function loadCategories() {
2321
2667
  try {
2322
2668
  const client = getClient();
2323
2669
  const result = await client.getCategories();
2324
- setCategories(result.categories.map((c) => ({ id: c.id, name: c.name })));
2670
+ setCategories(result.categories as CategoryNode[]);
2325
2671
  } catch {
2326
2672
  // Categories endpoint may not be available in all modes
2327
2673
  }
@@ -2390,6 +2736,10 @@ function ProductsContent() {
2390
2736
  router.push(\`/products?\${params.toString()}\`);
2391
2737
  }
2392
2738
 
2739
+ function handleCategorySelect(id: string) {
2740
+ updateParam('category', id);
2741
+ }
2742
+
2393
2743
  return (
2394
2744
  <div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
2395
2745
  {/* Page Header */}
@@ -2421,18 +2771,13 @@ function ProductsContent() {
2421
2771
  {tc('all')}
2422
2772
  </button>
2423
2773
  {categories.map((cat) => (
2424
- <button
2774
+ <CategoryChip
2425
2775
  key={cat.id}
2426
- onClick={() => updateParam('category', cat.id)}
2427
- className={cn(
2428
- 'rounded-full border px-3 py-1.5 text-sm transition-colors',
2429
- categoryId === cat.id
2430
- ? 'bg-primary text-primary-foreground border-primary'
2431
- : 'border-border text-muted-foreground hover:border-primary hover:text-foreground'
2432
- )}
2433
- >
2434
- {cat.name}
2435
- </button>
2776
+ category={cat}
2777
+ selectedId={categoryId}
2778
+ onSelect={handleCategorySelect}
2779
+ tc={tc as (key: string) => string}
2780
+ />
2436
2781
  ))}
2437
2782
  </div>
2438
2783
  )}
@@ -2514,7 +2859,18 @@ import { useEffect, useState, useMemo } from 'react';
2514
2859
  import { useParams } from 'next/navigation';
2515
2860
  import Image from 'next/image';
2516
2861
  import Link from 'next/link';
2517
- import type { Product, ProductVariant, ProductImage, ProductMetafield } from 'brainerce';
2862
+ import type {
2863
+ Product,
2864
+ ProductVariant,
2865
+ ProductImage,
2866
+ ProductMetafield,
2867
+ ProductRecommendationsResponse,
2868
+ DownloadFile,
2869
+ } from 'brainerce';
2870
+
2871
+ type ProductWithRecommendations = Product & {
2872
+ recommendations?: ProductRecommendationsResponse;
2873
+ };
2518
2874
  import { getProductPriceInfo, getDescriptionContent } from 'brainerce';
2519
2875
  import { getClient } from '@/lib/brainerce';
2520
2876
  import { useCart } from '@/providers/store-provider';
@@ -2522,6 +2878,7 @@ import { PriceDisplay } from '@/components/shared/price-display';
2522
2878
  import { LoadingSpinner } from '@/components/shared/loading-spinner';
2523
2879
  import { VariantSelector } from '@/components/products/variant-selector';
2524
2880
  import { StockBadge } from '@/components/products/stock-badge';
2881
+ import { RecommendationSection } from '@/components/products/recommendation-section';
2525
2882
  import { useTranslations } from '@/lib/translations';
2526
2883
  import { cn } from '@/lib/utils';
2527
2884
 
@@ -2615,7 +2972,7 @@ export default function ProductDetailPage() {
2615
2972
  const { refreshCart } = useCart();
2616
2973
  const t = useTranslations('productDetail');
2617
2974
  const tc = useTranslations('common');
2618
- const [product, setProduct] = useState<Product | null>(null);
2975
+ const [product, setProduct] = useState<ProductWithRecommendations | null>(null);
2619
2976
  const [loading, setLoading] = useState(true);
2620
2977
  const [error, setError] = useState<string | null>(null);
2621
2978
  const [selectedVariant, setSelectedVariant] = useState<ProductVariant | null>(null);
@@ -2624,6 +2981,8 @@ export default function ProductDetailPage() {
2624
2981
  const [addingToCart, setAddingToCart] = useState(false);
2625
2982
  const [addedMessage, setAddedMessage] = useState(false);
2626
2983
 
2984
+ const recommendations = product?.recommendations ?? null;
2985
+
2627
2986
  // Load product
2628
2987
  useEffect(() => {
2629
2988
  async function load() {
@@ -2632,7 +2991,7 @@ export default function ProductDetailPage() {
2632
2991
  setError(null);
2633
2992
  const client = getClient();
2634
2993
  const p = await client.getProductBySlug(slug);
2635
- setProduct(p);
2994
+ setProduct(p as ProductWithRecommendations);
2636
2995
 
2637
2996
  // Auto-select first variant
2638
2997
  if (p.variants && p.variants.length > 0) {
@@ -2838,8 +3197,43 @@ export default function ProductDetailPage() {
2838
3197
  size="lg"
2839
3198
  />
2840
3199
 
2841
- {/* Stock */}
2842
- <StockBadge inventory={inventory} lowStockThreshold={5} />
3200
+ {/* Stock / Digital badge */}
3201
+ {product.isDownloadable ? (
3202
+ <span className="inline-flex items-center gap-1.5 rounded-full bg-green-100 px-3 py-1 text-xs font-medium text-green-800 dark:bg-green-950/30 dark:text-green-400">
3203
+ <svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
3204
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
3205
+ </svg>
3206
+ {t('instantDownload')}
3207
+ </span>
3208
+ ) : (
3209
+ <StockBadge inventory={inventory} lowStockThreshold={5} />
3210
+ )}
3211
+
3212
+ {/* Downloadable files info */}
3213
+ {product.isDownloadable && product.downloads && product.downloads.length > 0 && (
3214
+ <div className="bg-muted/50 rounded-lg border p-4">
3215
+ <p className="text-foreground mb-2 text-sm font-medium">
3216
+ {t('filesIncluded', { count: product.downloads.length })}
3217
+ </p>
3218
+ <ul className="space-y-1.5">
3219
+ {product.downloads.map((file: DownloadFile) => (
3220
+ <li key={file.id} className="text-muted-foreground flex items-center gap-2 text-sm">
3221
+ <svg className="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
3222
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
3223
+ </svg>
3224
+ <span className="truncate">{file.name}</span>
3225
+ {file.size && (
3226
+ <span className="flex-shrink-0 text-xs">
3227
+ ({file.size < 1024 * 1024
3228
+ ? \`\${(file.size / 1024).toFixed(0)} KB\`
3229
+ : \`\${(file.size / (1024 * 1024)).toFixed(1)} MB\`})
3230
+ </span>
3231
+ )}
3232
+ </li>
3233
+ ))}
3234
+ </ul>
3235
+ </div>
3236
+ )}
2843
3237
 
2844
3238
  {/* Variant Selector */}
2845
3239
  {product.type === 'VARIABLE' && product.variants && product.variants.length > 0 && (
@@ -2903,6 +3297,13 @@ export default function ProductDetailPage() {
2903
3297
  </button>
2904
3298
  </div>
2905
3299
 
3300
+ {/* Download after purchase note */}
3301
+ {product.isDownloadable && (
3302
+ <p className="text-muted-foreground text-sm">
3303
+ {t('downloadAfterPurchase')}
3304
+ </p>
3305
+ )}
3306
+
2906
3307
  {/* Description */}
2907
3308
  {description && (
2908
3309
  <div className="border-border border-t pt-4">
@@ -2940,19 +3341,41 @@ export default function ProductDetailPage() {
2940
3341
  )}
2941
3342
  </div>
2942
3343
  </div>
3344
+
3345
+ {/* Upsells \u2014 premium alternatives (product page) */}
3346
+ {recommendations?.upsells && recommendations.upsells.length > 0 && (
3347
+ <RecommendationSection
3348
+ title={t('upgradeYourChoice')}
3349
+ items={recommendations.upsells}
3350
+ className="mt-12"
3351
+ />
3352
+ )}
3353
+
3354
+ {/* Related products \u2014 similar items (bottom of product page) */}
3355
+ {recommendations?.related && recommendations.related.length > 0 && (
3356
+ <RecommendationSection
3357
+ title={t('similarProducts')}
3358
+ items={recommendations.related}
3359
+ className="mt-12"
3360
+ />
3361
+ )}
2943
3362
  </div>
2944
3363
  );
2945
3364
  }
2946
3365
  `,
2947
3366
  "src/app/cart/page.tsx": `'use client';
2948
3367
 
3368
+ import { useEffect, useState } from 'react';
2949
3369
  import Link from 'next/link';
3370
+ import type { CartRecommendationsResponse } from 'brainerce';
3371
+ import { getClient } from '@/lib/brainerce';
2950
3372
  import { useCart } from '@/providers/store-provider';
2951
3373
  import { CartItem } from '@/components/cart/cart-item';
2952
3374
  import { CartSummary } from '@/components/cart/cart-summary';
2953
3375
  import { CouponInput } from '@/components/cart/coupon-input';
2954
3376
  import { CartNudges } from '@/components/cart/cart-nudges';
2955
3377
  import { ReservationCountdown } from '@/components/cart/reservation-countdown';
3378
+ import { CartRecommendationSection } from '@/components/products/recommendation-section';
2956
3379
  import { LoadingSpinner } from '@/components/shared/loading-spinner';
2957
3380
  import { useTranslations } from '@/lib/translations';
2958
3381
 
@@ -2960,6 +3383,17 @@ export default function CartPage() {
2960
3383
  const { cart, cartLoading, refreshCart, itemCount } = useCart();
2961
3384
  const t = useTranslations('cart');
2962
3385
  const tc = useTranslations('common');
3386
+ const [cartRecs, setCartRecs] = useState<CartRecommendationsResponse | null>(null);
3387
+
3388
+ // Load cross-sell recommendations when cart changes
3389
+ useEffect(() => {
3390
+ if (!cart?.id || cart.items.length === 0) {
3391
+ setCartRecs(null);
3392
+ return;
3393
+ }
3394
+ const client = getClient();
3395
+ client.getCartRecommendations(cart.id, 4).then(setCartRecs).catch(() => {});
3396
+ }, [cart?.id, cart?.items.length]);
2963
3397
 
2964
3398
  if (cartLoading) {
2965
3399
  return (
@@ -3051,6 +3485,15 @@ export default function CartPage() {
3051
3485
  </div>
3052
3486
  </div>
3053
3487
  </div>
3488
+
3489
+ {/* Cross-sell recommendations */}
3490
+ {cartRecs?.recommendations && cartRecs.recommendations.length > 0 && (
3491
+ <CartRecommendationSection
3492
+ title={t('youMightAlsoNeed')}
3493
+ items={cartRecs.recommendations}
3494
+ className="mt-10"
3495
+ />
3496
+ )}
3054
3497
  </div>
3055
3498
  );
3056
3499
  }
@@ -3934,8 +4377,8 @@ export default function OrderConfirmationPage() {
3934
4377
  import { useState } from 'react';
3935
4378
  import { useRouter } from 'next/navigation';
3936
4379
  import Link from 'next/link';
3937
- import { getClient } from '@/lib/brainerce';
3938
4380
  import { useAuth } from '@/providers/store-provider';
4381
+ import { proxyLogin } from '@/lib/auth';
3939
4382
  import { LoginForm } from '@/components/auth/login-form';
3940
4383
  import { OAuthButtons } from '@/components/auth/oauth-buttons';
3941
4384
  import { useTranslations } from '@/lib/translations';
@@ -3949,15 +4392,16 @@ export default function LoginPage() {
3949
4392
  async function handleLogin(email: string, password: string) {
3950
4393
  try {
3951
4394
  setError(null);
3952
- const client = getClient();
3953
- const result = await client.loginCustomer(email, password);
4395
+ const result = await proxyLogin(email, password);
3954
4396
 
3955
4397
  if (result.requiresVerification) {
3956
- router.push(\`/verify-email?token=\${encodeURIComponent(result.token)}\`);
4398
+ // Verification token is NOT the auth JWT \u2014 safe to pass in URL
4399
+ router.push('/verify-email');
3957
4400
  return;
3958
4401
  }
3959
4402
 
3960
- auth.login(result.token);
4403
+ // Cookie was set by the proxy; refresh auth state
4404
+ await auth.login();
3961
4405
  router.push('/');
3962
4406
  } catch (err) {
3963
4407
  const message = err instanceof Error ? err.message : 'Login failed. Please try again.';
@@ -3993,8 +4437,8 @@ export default function LoginPage() {
3993
4437
  import { useState } from 'react';
3994
4438
  import { useRouter } from 'next/navigation';
3995
4439
  import Link from 'next/link';
3996
- import { getClient } from '@/lib/brainerce';
3997
4440
  import { useAuth } from '@/providers/store-provider';
4441
+ import { proxyRegister } from '@/lib/auth';
3998
4442
  import { RegisterForm } from '@/components/auth/register-form';
3999
4443
  import { OAuthButtons } from '@/components/auth/oauth-buttons';
4000
4444
  import { useTranslations } from '@/lib/translations';
@@ -4013,20 +4457,16 @@ export default function RegisterPage() {
4013
4457
  }) {
4014
4458
  try {
4015
4459
  setError(null);
4016
- const client = getClient();
4017
- const result = await client.registerCustomer({
4018
- firstName: data.firstName,
4019
- lastName: data.lastName,
4020
- email: data.email,
4021
- password: data.password,
4022
- });
4460
+ const result = await proxyRegister(data);
4023
4461
 
4024
4462
  if (result.requiresVerification) {
4025
- router.push(\`/verify-email?token=\${encodeURIComponent(result.token)}\`);
4463
+ // Cookie already set by proxy; verify-email uses it for auth
4464
+ router.push('/verify-email');
4026
4465
  return;
4027
4466
  }
4028
4467
 
4029
- auth.login(result.token);
4468
+ // Cookie was set by the proxy; refresh auth state
4469
+ await auth.login();
4030
4470
  router.push('/');
4031
4471
  } catch (err) {
4032
4472
  const message = err instanceof Error ? err.message : 'Registration failed. Please try again.';
@@ -4060,10 +4500,10 @@ export default function RegisterPage() {
4060
4500
  "src/app/verify-email/page.tsx": `'use client';
4061
4501
 
4062
4502
  import { Suspense, useState, useRef, useEffect, useCallback } from 'react';
4063
- import { useRouter, useSearchParams } from 'next/navigation';
4503
+ import { useRouter } from 'next/navigation';
4064
4504
  import Link from 'next/link';
4065
- import { getClient } from '@/lib/brainerce';
4066
4505
  import { useAuth } from '@/providers/store-provider';
4506
+ import { proxyVerifyEmail, proxyResendVerification } from '@/lib/auth';
4067
4507
  import { LoadingSpinner } from '@/components/shared/loading-spinner';
4068
4508
  import { useTranslations } from '@/lib/translations';
4069
4509
 
@@ -4072,10 +4512,8 @@ const RESEND_COOLDOWN_SECONDS = 60;
4072
4512
 
4073
4513
  function VerifyEmailContent() {
4074
4514
  const router = useRouter();
4075
- const searchParams = useSearchParams();
4076
4515
  const auth = useAuth();
4077
4516
 
4078
- const token = searchParams.get('token');
4079
4517
  const t = useTranslations('auth');
4080
4518
 
4081
4519
  const [digits, setDigits] = useState<string[]>(Array(CODE_LENGTH).fill(''));
@@ -4103,18 +4541,17 @@ function VerifyEmailContent() {
4103
4541
 
4104
4542
  const handleSubmit = useCallback(
4105
4543
  async (code: string) => {
4106
- if (!token || code.length !== CODE_LENGTH || loading) return;
4544
+ if (code.length !== CODE_LENGTH || loading) return;
4107
4545
 
4108
4546
  try {
4109
4547
  setLoading(true);
4110
4548
  setError(null);
4111
- const client = getClient();
4112
- const result = await client.verifyEmail(code, token);
4549
+ // Auth token is in httpOnly cookie \u2014 proxy adds Authorization header
4550
+ const result = await proxyVerifyEmail(code);
4113
4551
 
4114
4552
  if (result.verified) {
4115
- // token field exists on newer SDK versions
4116
- const authToken = (result as unknown as { token?: string }).token || token;
4117
- auth.login(authToken);
4553
+ // Refresh auth state (cookie already set)
4554
+ await auth.login();
4118
4555
  setSuccess('Email verified successfully! Redirecting...');
4119
4556
  setTimeout(() => router.push('/'), 1500);
4120
4557
  } else {
@@ -4128,7 +4565,7 @@ function VerifyEmailContent() {
4128
4565
  setLoading(false);
4129
4566
  }
4130
4567
  },
4131
- [token, loading, auth, router]
4568
+ [loading, auth, router]
4132
4569
  );
4133
4570
 
4134
4571
  function handleDigitChange(index: number, value: string) {
@@ -4182,13 +4619,12 @@ function VerifyEmailContent() {
4182
4619
  }
4183
4620
 
4184
4621
  async function handleResend() {
4185
- if (!token || resending || cooldown > 0) return;
4622
+ if (resending || cooldown > 0) return;
4186
4623
 
4187
4624
  try {
4188
4625
  setResending(true);
4189
4626
  setError(null);
4190
- const client = getClient();
4191
- await client.resendVerificationEmail(token);
4627
+ await proxyResendVerification();
4192
4628
  setSuccess('Verification code sent! Check your email.');
4193
4629
  setCooldown(RESEND_COOLDOWN_SECONDS);
4194
4630
  // Clear digits for fresh entry
@@ -4208,37 +4644,6 @@ function VerifyEmailContent() {
4208
4644
  handleSubmit(code);
4209
4645
  }
4210
4646
 
4211
- // No token provided
4212
- if (!token) {
4213
- return (
4214
- <div className="flex min-h-[60vh] items-center justify-center px-4 py-12">
4215
- <div className="w-full max-w-md space-y-4 text-center">
4216
- <svg
4217
- className="text-muted-foreground mx-auto h-12 w-12"
4218
- fill="none"
4219
- viewBox="0 0 24 24"
4220
- stroke="currentColor"
4221
- >
4222
- <path
4223
- strokeLinecap="round"
4224
- strokeLinejoin="round"
4225
- strokeWidth={1.5}
4226
- 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"
4227
- />
4228
- </svg>
4229
- <h1 className="text-foreground text-2xl font-bold">{t('verificationInvalid')}</h1>
4230
- <p className="text-muted-foreground text-sm">{t('verificationInvalidDesc')}</p>
4231
- <Link
4232
- href="/register"
4233
- className="bg-primary text-primary-foreground inline-flex items-center rounded px-6 py-3 font-medium transition-opacity hover:opacity-90"
4234
- >
4235
- {t('goToRegister')}
4236
- </Link>
4237
- </div>
4238
- </div>
4239
- );
4240
- }
4241
-
4242
4647
  return (
4243
4648
  <div className="flex min-h-[60vh] items-center justify-center px-4 py-12">
4244
4649
  <div className="w-full max-w-md space-y-6">
@@ -4368,8 +4773,8 @@ function OAuthCallbackContent() {
4368
4773
  const t = useTranslations('auth');
4369
4774
 
4370
4775
  const oauthSuccess = searchParams.get('oauth_success');
4371
- const token = searchParams.get('token');
4372
4776
  const oauthError = searchParams.get('oauth_error');
4777
+ // Token is no longer in URL \u2014 it was set as httpOnly cookie by /api/auth/oauth-callback
4373
4778
 
4374
4779
  useEffect(() => {
4375
4780
  // Prevent double-processing in React StrictMode
@@ -4381,13 +4786,15 @@ function OAuthCallbackContent() {
4381
4786
  return;
4382
4787
  }
4383
4788
 
4384
- if (oauthSuccess === 'true' && token) {
4385
- auth.login(token);
4386
- router.push('/');
4789
+ if (oauthSuccess === 'true') {
4790
+ // Cookie was already set by the API route; refresh auth state
4791
+ auth.login().then(() => {
4792
+ router.push('/');
4793
+ });
4387
4794
  } else {
4388
4795
  setError(t('authFailedDesc'));
4389
4796
  }
4390
- }, [oauthSuccess, token, oauthError, auth, router]);
4797
+ }, [oauthSuccess, oauthError, auth, router, t]);
4391
4798
 
4392
4799
  if (error) {
4393
4800
  return (
@@ -4554,6 +4961,354 @@ export default function AccountPage() {
4554
4961
  </div>
4555
4962
  );
4556
4963
  }
4964
+ `,
4965
+ "src/app/api/store/[...path]/route.ts": `import { NextRequest, NextResponse } from 'next/server';
4966
+ import { cookies } from 'next/headers';
4967
+
4968
+ const BACKEND_URL = (process.env.BRAINERCE_API_URL || 'https://api.brainerce.com').replace(
4969
+ /\\/$/,
4970
+ ''
4971
+ );
4972
+
4973
+ const TOKEN_COOKIE = 'brainerce_customer_token';
4974
+ const LOGGED_IN_COOKIE = 'brainerce_logged_in';
4975
+ const CSRF_HEADER = 'x-requested-with';
4976
+ const CSRF_VALUE = 'brainerce';
4977
+
4978
+ const COOKIE_MAX_AGE = 7 * 24 * 60 * 60; // 7 days
4979
+
4980
+ /** Auth endpoints whose responses contain tokens to intercept */
4981
+ const AUTH_ENDPOINTS = ['customers/login', 'customers/register', 'customers/verify-email'];
4982
+
4983
+ function isAuthEndpoint(path: string): boolean {
4984
+ return AUTH_ENDPOINTS.some((ep) => path.endsWith(ep));
4985
+ }
4986
+
4987
+ function isSecure(): boolean {
4988
+ return process.env.NODE_ENV === 'production';
4989
+ }
4990
+
4991
+ function setAuthCookies(response: NextResponse, token: string): void {
4992
+ response.cookies.set(TOKEN_COOKIE, token, {
4993
+ httpOnly: true,
4994
+ secure: isSecure(),
4995
+ sameSite: 'lax',
4996
+ path: '/',
4997
+ maxAge: COOKIE_MAX_AGE,
4998
+ });
4999
+ response.cookies.set(LOGGED_IN_COOKIE, '1', {
5000
+ httpOnly: false,
5001
+ secure: isSecure(),
5002
+ sameSite: 'lax',
5003
+ path: '/',
5004
+ maxAge: COOKIE_MAX_AGE,
5005
+ });
5006
+ }
5007
+
5008
+ function clearAuthCookies(response: NextResponse): void {
5009
+ response.cookies.delete(TOKEN_COOKIE);
5010
+ response.cookies.delete(LOGGED_IN_COOKIE);
5011
+ }
5012
+
5013
+ async function proxyRequest(
5014
+ request: NextRequest,
5015
+ params: { path: string[] }
5016
+ ): Promise<NextResponse> {
5017
+ const method = request.method;
5018
+
5019
+ // CSRF protection for mutating requests
5020
+ if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
5021
+ const csrfHeader = request.headers.get(CSRF_HEADER);
5022
+ if (csrfHeader !== CSRF_VALUE) {
5023
+ return NextResponse.json({ error: 'CSRF validation failed' }, { status: 403 });
5024
+ }
5025
+ }
5026
+
5027
+ // Build backend URL from path segments
5028
+ const pathSegments = params.path.join('/');
5029
+ const backendUrl = new URL(\`\${BACKEND_URL}/\${pathSegments}\`);
5030
+
5031
+ // Forward query parameters
5032
+ request.nextUrl.searchParams.forEach((value, key) => {
5033
+ backendUrl.searchParams.set(key, value);
5034
+ });
5035
+
5036
+ // Build headers for backend request
5037
+ const headers: Record<string, string> = {
5038
+ 'Content-Type': 'application/json',
5039
+ };
5040
+
5041
+ // Forward Origin/Referer so backend BrowserOriginGuard accepts proxied requests
5042
+ const origin = request.headers.get('origin');
5043
+ const referer = request.headers.get('referer');
5044
+ if (origin) headers['Origin'] = origin;
5045
+ if (referer) headers['Referer'] = referer;
5046
+
5047
+ // Forward SDK version header if present
5048
+ const sdkVersion = request.headers.get('x-sdk-version');
5049
+ if (sdkVersion) {
5050
+ headers['X-SDK-Version'] = sdkVersion;
5051
+ }
5052
+
5053
+ // Add auth token from httpOnly cookie
5054
+ const cookieStore = await cookies();
5055
+ const tokenCookie = cookieStore.get(TOKEN_COOKIE);
5056
+ if (tokenCookie?.value) {
5057
+ headers['Authorization'] = \`Bearer \${tokenCookie.value}\`;
5058
+ }
5059
+
5060
+ // Forward request body for non-GET requests
5061
+ let body: string | undefined;
5062
+ if (method !== 'GET' && method !== 'HEAD') {
5063
+ try {
5064
+ body = await request.text();
5065
+ } catch {
5066
+ // No body
5067
+ }
5068
+ }
5069
+
5070
+ // Proxy the request to backend
5071
+ let backendResponse: Response;
5072
+ try {
5073
+ backendResponse = await fetch(backendUrl.toString(), {
5074
+ method,
5075
+ headers,
5076
+ body,
5077
+ });
5078
+ } catch (error) {
5079
+ return NextResponse.json({ error: 'Backend service unavailable' }, { status: 502 });
5080
+ }
5081
+
5082
+ // Read response body
5083
+ const responseText = await backendResponse.text();
5084
+
5085
+ // For auth endpoints: intercept token, set cookie, strip token from response
5086
+ if (backendResponse.ok && method === 'POST' && isAuthEndpoint(pathSegments)) {
5087
+ try {
5088
+ const data = JSON.parse(responseText);
5089
+ if (data.token) {
5090
+ const token = data.token;
5091
+
5092
+ // Strip token from client response
5093
+ const { token: _stripped, ...safeData } = data;
5094
+
5095
+ const response = NextResponse.json(safeData, {
5096
+ status: backendResponse.status,
5097
+ });
5098
+ setAuthCookies(response, token);
5099
+ return response;
5100
+ }
5101
+ } catch {
5102
+ // Not JSON or no token field \u2014 pass through
5103
+ }
5104
+ }
5105
+
5106
+ // Handle 401 responses: clear auth cookies
5107
+ if (backendResponse.status === 401 && tokenCookie?.value) {
5108
+ const response = new NextResponse(responseText, {
5109
+ status: backendResponse.status,
5110
+ headers: {
5111
+ 'Content-Type': backendResponse.headers.get('Content-Type') || 'application/json',
5112
+ },
5113
+ });
5114
+ clearAuthCookies(response);
5115
+ return response;
5116
+ }
5117
+
5118
+ // Pass through response as-is
5119
+ return new NextResponse(responseText, {
5120
+ status: backendResponse.status,
5121
+ headers: {
5122
+ 'Content-Type': backendResponse.headers.get('Content-Type') || 'application/json',
5123
+ },
5124
+ });
5125
+ }
5126
+
5127
+ export async function GET(
5128
+ request: NextRequest,
5129
+ { params }: { params: Promise<{ path: string[] }> }
5130
+ ) {
5131
+ return proxyRequest(request, await params);
5132
+ }
5133
+
5134
+ export async function POST(
5135
+ request: NextRequest,
5136
+ { params }: { params: Promise<{ path: string[] }> }
5137
+ ) {
5138
+ return proxyRequest(request, await params);
5139
+ }
5140
+
5141
+ export async function PUT(
5142
+ request: NextRequest,
5143
+ { params }: { params: Promise<{ path: string[] }> }
5144
+ ) {
5145
+ return proxyRequest(request, await params);
5146
+ }
5147
+
5148
+ export async function PATCH(
5149
+ request: NextRequest,
5150
+ { params }: { params: Promise<{ path: string[] }> }
5151
+ ) {
5152
+ return proxyRequest(request, await params);
5153
+ }
5154
+
5155
+ export async function DELETE(
5156
+ request: NextRequest,
5157
+ { params }: { params: Promise<{ path: string[] }> }
5158
+ ) {
5159
+ return proxyRequest(request, await params);
5160
+ }
5161
+ `,
5162
+ "src/app/api/auth/oauth-callback/route.ts": `import { NextRequest, NextResponse } from 'next/server';
5163
+
5164
+ const TOKEN_COOKIE = 'brainerce_customer_token';
5165
+ const LOGGED_IN_COOKIE = 'brainerce_logged_in';
5166
+ const COOKIE_MAX_AGE = 7 * 24 * 60 * 60; // 7 days
5167
+
5168
+ function isSecure(): boolean {
5169
+ return process.env.NODE_ENV === 'production';
5170
+ }
5171
+
5172
+ /**
5173
+ * OAuth callback handler.
5174
+ * The backend redirects here with ?token=jwt&oauth_success=true after OAuth code exchange.
5175
+ * We set the httpOnly cookie and redirect to the client-side callback page (without the token).
5176
+ */
5177
+ export async function GET(request: NextRequest) {
5178
+ const { searchParams } = request.nextUrl;
5179
+ const token = searchParams.get('token');
5180
+ const oauthSuccess = searchParams.get('oauth_success');
5181
+ const oauthError = searchParams.get('oauth_error');
5182
+
5183
+ // Build redirect URL to client-side callback page
5184
+ const redirectUrl = new URL('/auth/callback', request.url);
5185
+
5186
+ if (oauthError) {
5187
+ redirectUrl.searchParams.set('oauth_error', oauthError);
5188
+ return NextResponse.redirect(redirectUrl);
5189
+ }
5190
+
5191
+ if (oauthSuccess === 'true' && token) {
5192
+ redirectUrl.searchParams.set('oauth_success', 'true');
5193
+
5194
+ const response = NextResponse.redirect(redirectUrl);
5195
+
5196
+ // Set httpOnly cookie with the token
5197
+ response.cookies.set(TOKEN_COOKIE, token, {
5198
+ httpOnly: true,
5199
+ secure: isSecure(),
5200
+ sameSite: 'lax',
5201
+ path: '/',
5202
+ maxAge: COOKIE_MAX_AGE,
5203
+ });
5204
+
5205
+ // Set indicator cookie (readable by client JS)
5206
+ response.cookies.set(LOGGED_IN_COOKIE, '1', {
5207
+ httpOnly: false,
5208
+ secure: isSecure(),
5209
+ sameSite: 'lax',
5210
+ path: '/',
5211
+ maxAge: COOKIE_MAX_AGE,
5212
+ });
5213
+
5214
+ return response;
5215
+ }
5216
+
5217
+ // Fallback: no token or success flag
5218
+ redirectUrl.searchParams.set('oauth_error', 'Authentication failed');
5219
+ return NextResponse.redirect(redirectUrl);
5220
+ }
5221
+ `,
5222
+ "src/app/api/auth/me/route.ts": `import { NextResponse } from 'next/server';
5223
+ import { cookies } from 'next/headers';
5224
+
5225
+ const BACKEND_URL = (process.env.BRAINERCE_API_URL || 'https://api.brainerce.com').replace(
5226
+ /\\/$/,
5227
+ ''
5228
+ );
5229
+
5230
+ const CONNECTION_ID = process.env.NEXT_PUBLIC_BRAINERCE_CONNECTION_ID || '';
5231
+
5232
+ const TOKEN_COOKIE = 'brainerce_customer_token';
5233
+ const LOGGED_IN_COOKIE = 'brainerce_logged_in';
5234
+
5235
+ /**
5236
+ * Auth status check endpoint.
5237
+ * Reads the httpOnly cookie, validates against backend, returns auth state.
5238
+ */
5239
+ export async function GET() {
5240
+ const cookieStore = await cookies();
5241
+ const tokenCookie = cookieStore.get(TOKEN_COOKIE);
5242
+
5243
+ if (!tokenCookie?.value) {
5244
+ return NextResponse.json({ isLoggedIn: false });
5245
+ }
5246
+
5247
+ try {
5248
+ // Validate token by calling backend profile endpoint
5249
+ const response = await fetch(\`\${BACKEND_URL}/api/vc/\${CONNECTION_ID}/customers/me\`, {
5250
+ headers: {
5251
+ Authorization: \`Bearer \${tokenCookie.value}\`,
5252
+ 'Content-Type': 'application/json',
5253
+ },
5254
+ });
5255
+
5256
+ if (!response.ok) {
5257
+ // Token is invalid or expired \u2014 clear cookies
5258
+ const res = NextResponse.json({ isLoggedIn: false });
5259
+ res.cookies.delete(TOKEN_COOKIE);
5260
+ res.cookies.delete(LOGGED_IN_COOKIE);
5261
+ return res;
5262
+ }
5263
+
5264
+ const customer = await response.json();
5265
+ return NextResponse.json({ isLoggedIn: true, customer });
5266
+ } catch {
5267
+ // Backend unreachable \u2014 don't clear cookies, might be temporary
5268
+ return NextResponse.json({ isLoggedIn: false, error: 'Service unavailable' }, { status: 503 });
5269
+ }
5270
+ }
5271
+ `,
5272
+ "src/app/api/auth/logout/route.ts": `import { NextResponse } from 'next/server';
5273
+
5274
+ const TOKEN_COOKIE = 'brainerce_customer_token';
5275
+ const LOGGED_IN_COOKIE = 'brainerce_logged_in';
5276
+
5277
+ /**
5278
+ * Logout endpoint. Clears auth cookies.
5279
+ */
5280
+ export async function POST() {
5281
+ const response = NextResponse.json({ success: true });
5282
+ response.cookies.delete(TOKEN_COOKIE);
5283
+ response.cookies.delete(LOGGED_IN_COOKIE);
5284
+ return response;
5285
+ }
5286
+ `,
5287
+ "src/middleware.ts": `import { NextRequest, NextResponse } from 'next/server';
5288
+
5289
+ const TOKEN_COOKIE = 'brainerce_customer_token';
5290
+
5291
+ /** Routes that require customer authentication */
5292
+ const PROTECTED_PATHS = ['/account'];
5293
+
5294
+ export function middleware(request: NextRequest) {
5295
+ const { pathname } = request.nextUrl;
5296
+ const isProtected = PROTECTED_PATHS.some((p) => pathname.startsWith(p));
5297
+
5298
+ if (isProtected) {
5299
+ const token = request.cookies.get(TOKEN_COOKIE);
5300
+ if (!token?.value) {
5301
+ const loginUrl = new URL('/login', request.url);
5302
+ return NextResponse.redirect(loginUrl);
5303
+ }
5304
+ }
5305
+
5306
+ return NextResponse.next();
5307
+ }
5308
+
5309
+ export const config = {
5310
+ matcher: ['/account/:path*'],
5311
+ };
4557
5312
  `,
4558
5313
  "src/components/products/product-card.tsx": `'use client';
4559
5314
 
@@ -4574,6 +5329,7 @@ interface ProductCardProps {
4574
5329
 
4575
5330
  export function ProductCard({ product, className }: ProductCardProps) {
4576
5331
  const t = useTranslations('common');
5332
+ const tp = useTranslations('productDetail');
4577
5333
  const { price, originalPrice, isOnSale } = getProductPriceInfo(product);
4578
5334
  const mainImage = product.images?.[0];
4579
5335
  const imageUrl = mainImage?.url || null;
@@ -4618,6 +5374,11 @@ export function ProductCard({ product, className }: ProductCardProps) {
4618
5374
  </span>
4619
5375
  )}
4620
5376
  <DiscountBadge discount={product.discount} />
5377
+ {product.isDownloadable && (
5378
+ <span className="bg-primary text-primary-foreground rounded px-2 py-1 text-xs font-bold">
5379
+ {tp('digitalProduct')}
5380
+ </span>
5381
+ )}
4621
5382
  </div>
4622
5383
  </div>
4623
5384
 
@@ -5885,8 +6646,11 @@ declare global {
5885
6646
  }
5886
6647
 
5887
6648
  // Payment SDK script URLs \u2014 resolved per provider
6649
+ // SRI hashes must be regenerated when the provider updates their SDK
5888
6650
  const PAYMENT_SDK_URL = 'https://cdn.meshulam.co.il/sdk/gs.min.js';
6651
+ const PAYMENT_SDK_SRI = 'sha384-e1OYzZERZLgbR8Zw1I4Ww/J7qXGyEsZb7LF0z5W/sbnTsFwtxop7sKZsLGNp6CB1';
5889
6652
  const APPLE_PAY_SDK_URL = 'https://meshulam.co.il/_media/js/apple_pay_sdk/sdk.min.js';
6653
+ const APPLE_PAY_SDK_SRI = 'sha384-CG67HXmWBeyuRR/jqFsYr6dGEMFyuZw3/hRmcyACJRYHGb/1ebEkQZyzdJXDc9/u';
5890
6654
 
5891
6655
  interface PaymentStepProps {
5892
6656
  checkoutId: string;
@@ -5897,7 +6661,7 @@ interface PaymentStepProps {
5897
6661
  * Load a script tag if not already present in the DOM.
5898
6662
  * Resolves when the script loads (or immediately if already present).
5899
6663
  */
5900
- function loadScript(src: string, optional = false): Promise<void> {
6664
+ function loadScript(src: string, optional = false, integrity?: string): Promise<void> {
5901
6665
  return new Promise((resolve) => {
5902
6666
  if (document.querySelector(\`script[src="\${src}"]\`)) {
5903
6667
  resolve();
@@ -5906,6 +6670,10 @@ function loadScript(src: string, optional = false): Promise<void> {
5906
6670
  const script = document.createElement('script');
5907
6671
  script.src = src;
5908
6672
  script.async = true;
6673
+ if (integrity) {
6674
+ script.integrity = integrity;
6675
+ script.crossOrigin = 'anonymous';
6676
+ }
5909
6677
  script.onload = () => resolve();
5910
6678
  script.onerror = () => {
5911
6679
  if (optional) {
@@ -5998,10 +6766,10 @@ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
5998
6766
 
5999
6767
  async function loadSdkScripts() {
6000
6768
  // Load Apple Pay SDK (optional)
6001
- await loadScript(APPLE_PAY_SDK_URL, true);
6769
+ await loadScript(APPLE_PAY_SDK_URL, true, APPLE_PAY_SDK_SRI);
6002
6770
 
6003
6771
  // Load payment SDK script
6004
- await loadScript(PAYMENT_SDK_URL);
6772
+ await loadScript(PAYMENT_SDK_URL, false, PAYMENT_SDK_SRI);
6005
6773
 
6006
6774
  // Wait for SDK global to be set by the script
6007
6775
  const available = await waitForPaymentSdkGlobal();
@@ -6664,7 +7432,7 @@ export function OAuthButtons({ className }: OAuthButtonsProps) {
6664
7432
  try {
6665
7433
  setRedirecting(provider);
6666
7434
  const client = getClient();
6667
- const redirectUrl = window.location.origin + '/auth/callback';
7435
+ const redirectUrl = window.location.origin + '/api/auth/oauth-callback';
6668
7436
  const result = await client.getOAuthAuthorizeUrl(provider, { redirectUrl });
6669
7437
  window.location.href = result.authorizationUrl;
6670
7438
  } catch (err) {
@@ -6946,10 +7714,11 @@ export function ProfileSection({ profile, onProfileUpdate, className }: ProfileS
6946
7714
  `,
6947
7715
  "src/components/account/order-history.tsx": `'use client';
6948
7716
 
6949
- import { useState } from 'react';
7717
+ import { useState, useEffect } from 'react';
6950
7718
  import Image from 'next/image';
6951
- import type { Order, OrderStatus } from 'brainerce';
7719
+ import type { Order, OrderStatus, OrderDownloadLink } from 'brainerce';
6952
7720
  import { formatPrice } from 'brainerce';
7721
+ import { getClient } from '@/lib/brainerce';
6953
7722
  import { useTranslations } from '@/lib/translations';
6954
7723
  import { cn } from '@/lib/utils';
6955
7724
 
@@ -7133,6 +7902,11 @@ function OrderCard({ order }: { order: Order }) {
7133
7902
  </div>
7134
7903
  ))}
7135
7904
 
7905
+ {/* Downloads section */}
7906
+ {order.hasDownloads && (
7907
+ <OrderDownloads orderId={order.id} />
7908
+ )}
7909
+
7136
7910
  <OrderFinancialSummary order={order} currency={currency} />
7137
7911
  </div>
7138
7912
  )}
@@ -7140,6 +7914,71 @@ function OrderCard({ order }: { order: Order }) {
7140
7914
  );
7141
7915
  }
7142
7916
 
7917
+ function OrderDownloads({ orderId }: { orderId: string }) {
7918
+ const t = useTranslations('account');
7919
+ const [downloads, setDownloads] = useState<OrderDownloadLink[] | null>(null);
7920
+ const [loading, setLoading] = useState(true);
7921
+
7922
+ useEffect(() => {
7923
+ let cancelled = false;
7924
+ async function fetch() {
7925
+ try {
7926
+ const client = getClient();
7927
+ const links = await client.getOrderDownloads(orderId);
7928
+ if (!cancelled) setDownloads(links);
7929
+ } catch {
7930
+ if (!cancelled) setDownloads([]);
7931
+ } finally {
7932
+ if (!cancelled) setLoading(false);
7933
+ }
7934
+ }
7935
+ fetch();
7936
+ return () => { cancelled = true; };
7937
+ }, [orderId]);
7938
+
7939
+ if (loading) {
7940
+ return (
7941
+ <div className="border-border border-t pt-2">
7942
+ <p className="text-muted-foreground animate-pulse text-xs">{t('downloads')}...</p>
7943
+ </div>
7944
+ );
7945
+ }
7946
+
7947
+ if (!downloads || downloads.length === 0) return null;
7948
+
7949
+ return (
7950
+ <div className="border-border space-y-2 border-t pt-2">
7951
+ <p className="text-foreground text-sm font-medium">{t('downloads')}</p>
7952
+ {downloads.map((link, idx) => (
7953
+ <div key={idx} className="flex items-center gap-3">
7954
+ <div className="min-w-0 flex-1">
7955
+ <p className="text-foreground truncate text-sm">{link.fileName}</p>
7956
+ <p className="text-muted-foreground text-xs">
7957
+ {link.productName}
7958
+ {' \xB7 '}
7959
+ {link.downloadLimit != null
7960
+ ? t('downloadsRemaining', { used: link.downloadsUsed, limit: link.downloadLimit })
7961
+ : t('unlimitedDownloads')}
7962
+ {' \xB7 '}
7963
+ {link.expiresAt
7964
+ ? t('expiresAt', { date: new Date(link.expiresAt).toLocaleDateString() })
7965
+ : t('noExpiry')}
7966
+ </p>
7967
+ </div>
7968
+ <a
7969
+ href={link.downloadUrl}
7970
+ target="_blank"
7971
+ rel="noopener noreferrer"
7972
+ className="bg-primary text-primary-foreground flex-shrink-0 rounded px-3 py-1 text-xs font-medium hover:opacity-90"
7973
+ >
7974
+ {t('downloadFile')}
7975
+ </a>
7976
+ </div>
7977
+ ))}
7978
+ </div>
7979
+ );
7980
+ }
7981
+
7143
7982
  function OrderFinancialSummary({ order, currency }: { order: Order; currency: string }) {
7144
7983
  const tc = useTranslations('common');
7145
7984
  const totalAmount = order.totalAmount || order.total || '0';
@@ -8021,7 +8860,7 @@ var DETAILED = `# Required Pages \u2014 Detailed Guide
8021
8860
  - Stock badge
8022
8861
  - Discount badge (\`getProductDiscountBadge()\`)
8023
8862
  - Add to cart button (disabled when \`!canPurchase\`)
8024
- - Product recommendations (\`getProductRecommendations()\`)
8863
+ - Product recommendations (from \`product.recommendations\` \u2014 embedded in product response)
8025
8864
  - Use \`client.getProductBySlug(slug)\`
8026
8865
 
8027
8866
  ## 4. Cart (\`/cart\`)
@@ -8299,6 +9138,39 @@ my-store/
8299
9138
  | \`app/auth/callback/page.tsx\` | Extracts OAuth token from URL params, stores in localStorage |
8300
9139
  | \`app/account/page.tsx\` | Protected page: getMyProfile() + getMyOrders() |
8301
9140
  | \`components/Header.tsx\` | Site header with nav, cart count badge, search autocomplete |
9141
+
9142
+ ## Content Security Policy (CSP)
9143
+
9144
+ Payment SDKs load iframes and scripts from external domains. Add these CSP headers in \`next.config.js\`:
9145
+
9146
+ \`\`\`js
9147
+ // next.config.js
9148
+ module.exports = {
9149
+ async headers() {
9150
+ return [{
9151
+ source: '/(.*)',
9152
+ headers: [{
9153
+ key: 'Content-Security-Policy',
9154
+ value: [
9155
+ "default-src 'self'",
9156
+ "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",
9157
+ "style-src 'self' 'unsafe-inline'",
9158
+ "img-src 'self' data: blob: https:",
9159
+ "font-src 'self' data:",
9160
+ "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",
9161
+ "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",
9162
+ "worker-src 'self' blob:",
9163
+ ].join('; '),
9164
+ }],
9165
+ }];
9166
+ },
9167
+ };
9168
+ \`\`\`
9169
+
9170
+ **Required domains by provider:**
9171
+ - **Grow**: \`*.meshulam.co.il\`, \`grow.link\`, \`*.grow.link\`, \`*.grow.security\`, \`*.creditguard.co.il\`
9172
+ - **Stripe**: \`js.stripe.com\`, \`hooks.stripe.com\`, \`*.stripe.com\`
9173
+ - **Google Pay**: \`pay.google.com\`, \`google.com\`
8302
9174
  `;
8303
9175
 
8304
9176
  // src/resources/project-template.ts