@brainerce/mcp-server 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/http.js +1023 -151
- package/dist/bin/stdio.js +1023 -151
- package/dist/index.d.mts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1023 -151
- package/dist/index.mjs +1023 -151
- package/package.json +2 -2
package/dist/bin/stdio.js
CHANGED
|
@@ -713,20 +713,25 @@ Display: banners in header, badges on product cards (strikethrough + discounted
|
|
|
713
713
|
function getRecommendationsSection() {
|
|
714
714
|
return `## Product Recommendations (Cross-Sells & Upsells)
|
|
715
715
|
|
|
716
|
+
Recommendations come **embedded in the product response** \u2014 no extra API call needed for product pages.
|
|
717
|
+
|
|
716
718
|
\`\`\`typescript
|
|
717
719
|
import type { ProductRecommendation, ProductRecommendationsResponse, CartRecommendationsResponse } from 'brainerce';
|
|
718
720
|
|
|
719
|
-
// Product page \u2014
|
|
720
|
-
const
|
|
721
|
-
|
|
721
|
+
// Product page \u2014 recommendations are embedded in the product response
|
|
722
|
+
const product = await client.getProductBySlug('some-slug');
|
|
723
|
+
const recs = (product as any).recommendations as ProductRecommendationsResponse | undefined;
|
|
724
|
+
// recs?.upsells \u2014 premium alternatives (show on product page)
|
|
725
|
+
// recs?.related \u2014 similar products (show at bottom of product page)
|
|
726
|
+
// recs?.crossSells \u2014 complementary products (typically used on cart page)
|
|
722
727
|
|
|
723
|
-
// Cart page \u2014 cross-sell suggestions for cart items
|
|
728
|
+
// Cart page \u2014 cross-sell suggestions for cart items (separate call)
|
|
724
729
|
const cartRecs: CartRecommendationsResponse = await client.getCartRecommendations(cartId, 4);
|
|
725
|
-
// cartRecs.recommendations \u2014 deduplicated cross-sells
|
|
730
|
+
// cartRecs.recommendations \u2014 deduplicated cross-sells (excludes items already in cart)
|
|
726
731
|
\`\`\`
|
|
727
732
|
|
|
728
733
|
ProductRecommendation: \`id\`, \`name\`, \`slug\`, \`basePrice\`, \`salePrice\`, \`images\`, \`type\`, \`inventory\`, \`relationType\`.
|
|
729
|
-
Display
|
|
734
|
+
Display upsells + related on product pages, cross-sells on cart page.`;
|
|
730
735
|
}
|
|
731
736
|
function getInventorySection() {
|
|
732
737
|
return `## Inventory, Stock Display & Reservation Countdown
|
|
@@ -1895,39 +1900,24 @@ var EMBEDDED_TEMPLATES = {
|
|
|
1895
1900
|
"src/lib/brainerce.ts.ejs": `import { BrainerceClient } from 'brainerce';
|
|
1896
1901
|
|
|
1897
1902
|
const CONNECTION_ID = process.env.NEXT_PUBLIC_BRAINERCE_CONNECTION_ID || 'vc_YOUR_CONNECTION_ID';
|
|
1898
|
-
const API_URL = process.env.NEXT_PUBLIC_BRAINERCE_API_URL || 'https://api.brainerce.com';
|
|
1899
1903
|
|
|
1900
|
-
// Singleton SDK client
|
|
1904
|
+
// Singleton SDK client \u2014 routes through same-origin BFF proxy for httpOnly cookie auth
|
|
1901
1905
|
let clientInstance: BrainerceClient | null = null;
|
|
1902
1906
|
|
|
1903
1907
|
export function getClient(): BrainerceClient {
|
|
1904
1908
|
if (!clientInstance) {
|
|
1905
1909
|
clientInstance = new BrainerceClient({
|
|
1906
1910
|
connectionId: CONNECTION_ID,
|
|
1907
|
-
baseUrl:
|
|
1911
|
+
baseUrl: '/api/store', // same-origin proxy handles auth via httpOnly cookie
|
|
1912
|
+
proxyMode: true, // skip client-side token checks; proxy adds Authorization header
|
|
1908
1913
|
});
|
|
1909
1914
|
}
|
|
1910
1915
|
return clientInstance;
|
|
1911
1916
|
}
|
|
1912
1917
|
|
|
1913
|
-
//
|
|
1914
|
-
const TOKEN_KEY = 'brainerce_customer_token';
|
|
1918
|
+
// Cart ID helpers (not a security token \u2014 safe in localStorage)
|
|
1915
1919
|
const CART_ID_KEY = 'brainerce_cart_id';
|
|
1916
1920
|
|
|
1917
|
-
export function getStoredToken(): string | null {
|
|
1918
|
-
if (typeof window === 'undefined') return null;
|
|
1919
|
-
return localStorage.getItem(TOKEN_KEY);
|
|
1920
|
-
}
|
|
1921
|
-
|
|
1922
|
-
export function setStoredToken(token: string | null): void {
|
|
1923
|
-
if (typeof window === 'undefined') return;
|
|
1924
|
-
if (token) {
|
|
1925
|
-
localStorage.setItem(TOKEN_KEY, token);
|
|
1926
|
-
} else {
|
|
1927
|
-
localStorage.removeItem(TOKEN_KEY);
|
|
1928
|
-
}
|
|
1929
|
-
}
|
|
1930
|
-
|
|
1931
1921
|
export function getStoredCartId(): string | null {
|
|
1932
1922
|
if (typeof window === 'undefined') return null;
|
|
1933
1923
|
return localStorage.getItem(CART_ID_KEY);
|
|
@@ -1942,14 +1932,158 @@ export function setStoredCartId(cartId: string | null): void {
|
|
|
1942
1932
|
}
|
|
1943
1933
|
}
|
|
1944
1934
|
|
|
1945
|
-
// Initialize client
|
|
1935
|
+
// Initialize client (no token hydration \u2014 auth handled by httpOnly cookie)
|
|
1946
1936
|
export function initClient(): BrainerceClient {
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1937
|
+
return getClient();
|
|
1938
|
+
}
|
|
1939
|
+
`,
|
|
1940
|
+
"src/lib/auth.ts": `/**
|
|
1941
|
+
* Client-side auth helpers that call the BFF proxy API routes.
|
|
1942
|
+
* All mutating requests include the CSRF header.
|
|
1943
|
+
* The token is managed server-side via httpOnly cookies \u2014 never exposed to JS.
|
|
1944
|
+
*/
|
|
1945
|
+
|
|
1946
|
+
const CONNECTION_ID = process.env.NEXT_PUBLIC_BRAINERCE_CONNECTION_ID || '';
|
|
1947
|
+
|
|
1948
|
+
const CSRF_HEADERS: Record<string, string> = {
|
|
1949
|
+
'Content-Type': 'application/json',
|
|
1950
|
+
'X-Requested-With': 'brainerce',
|
|
1951
|
+
};
|
|
1952
|
+
|
|
1953
|
+
interface LoginResult {
|
|
1954
|
+
customer: {
|
|
1955
|
+
id: string;
|
|
1956
|
+
email: string;
|
|
1957
|
+
firstName?: string;
|
|
1958
|
+
lastName?: string;
|
|
1959
|
+
emailVerified: boolean;
|
|
1960
|
+
};
|
|
1961
|
+
expiresAt: string;
|
|
1962
|
+
requiresVerification?: boolean;
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
interface RegisterResult {
|
|
1966
|
+
customer: {
|
|
1967
|
+
id: string;
|
|
1968
|
+
email: string;
|
|
1969
|
+
firstName?: string;
|
|
1970
|
+
lastName?: string;
|
|
1971
|
+
emailVerified: boolean;
|
|
1972
|
+
};
|
|
1973
|
+
expiresAt: string;
|
|
1974
|
+
requiresVerification?: boolean;
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
interface AuthStatus {
|
|
1978
|
+
isLoggedIn: boolean;
|
|
1979
|
+
customer?: {
|
|
1980
|
+
id: string;
|
|
1981
|
+
email: string;
|
|
1982
|
+
firstName?: string;
|
|
1983
|
+
lastName?: string;
|
|
1984
|
+
phone?: string;
|
|
1985
|
+
emailVerified: boolean;
|
|
1986
|
+
};
|
|
1987
|
+
error?: string;
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
interface VerifyEmailResult {
|
|
1991
|
+
verified: boolean;
|
|
1992
|
+
message?: string;
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
async function handleResponse<T>(response: Response): Promise<T> {
|
|
1996
|
+
const data = await response.json();
|
|
1997
|
+
if (!response.ok) {
|
|
1998
|
+
throw new Error(data.message || data.error || \`Request failed (\${response.status})\`);
|
|
1951
1999
|
}
|
|
1952
|
-
return
|
|
2000
|
+
return data as T;
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
/**
|
|
2004
|
+
* Login via BFF proxy. The proxy sets the httpOnly cookie on success.
|
|
2005
|
+
*/
|
|
2006
|
+
export async function proxyLogin(email: string, password: string): Promise<LoginResult> {
|
|
2007
|
+
const response = await fetch(\`/api/store/api/vc/\${CONNECTION_ID}/customers/login\`, {
|
|
2008
|
+
method: 'POST',
|
|
2009
|
+
headers: CSRF_HEADERS,
|
|
2010
|
+
body: JSON.stringify({ email, password }),
|
|
2011
|
+
});
|
|
2012
|
+
return handleResponse<LoginResult>(response);
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
/**
|
|
2016
|
+
* Register via BFF proxy. The proxy sets the httpOnly cookie on success.
|
|
2017
|
+
*/
|
|
2018
|
+
export async function proxyRegister(data: {
|
|
2019
|
+
firstName: string;
|
|
2020
|
+
lastName: string;
|
|
2021
|
+
email: string;
|
|
2022
|
+
password: string;
|
|
2023
|
+
}): Promise<RegisterResult> {
|
|
2024
|
+
const response = await fetch(\`/api/store/api/vc/\${CONNECTION_ID}/customers/register\`, {
|
|
2025
|
+
method: 'POST',
|
|
2026
|
+
headers: CSRF_HEADERS,
|
|
2027
|
+
body: JSON.stringify(data),
|
|
2028
|
+
});
|
|
2029
|
+
return handleResponse<RegisterResult>(response);
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
/**
|
|
2033
|
+
* Check auth status. Reads httpOnly cookie server-side and validates with backend.
|
|
2034
|
+
*/
|
|
2035
|
+
export async function checkAuthStatus(): Promise<AuthStatus> {
|
|
2036
|
+
const response = await fetch('/api/auth/me');
|
|
2037
|
+
return response.json();
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
/**
|
|
2041
|
+
* Logout. Clears httpOnly auth cookies server-side.
|
|
2042
|
+
*/
|
|
2043
|
+
export async function proxyLogout(): Promise<void> {
|
|
2044
|
+
await fetch('/api/auth/logout', {
|
|
2045
|
+
method: 'POST',
|
|
2046
|
+
headers: { 'X-Requested-With': 'brainerce' },
|
|
2047
|
+
});
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
/**
|
|
2051
|
+
* Verify email via BFF proxy. The auth token is in the httpOnly cookie (set during login/register).
|
|
2052
|
+
* The proxy adds the Authorization header automatically.
|
|
2053
|
+
*/
|
|
2054
|
+
export async function proxyVerifyEmail(code: string): Promise<VerifyEmailResult> {
|
|
2055
|
+
const response = await fetch(\`/api/store/api/vc/\${CONNECTION_ID}/customers/verify-email\`, {
|
|
2056
|
+
method: 'POST',
|
|
2057
|
+
headers: CSRF_HEADERS,
|
|
2058
|
+
body: JSON.stringify({ code }),
|
|
2059
|
+
});
|
|
2060
|
+
return handleResponse<VerifyEmailResult>(response);
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
/**
|
|
2064
|
+
* Resend verification email via BFF proxy.
|
|
2065
|
+
* Uses the auth token from the httpOnly cookie.
|
|
2066
|
+
*/
|
|
2067
|
+
export async function proxyResendVerification(): Promise<{ message: string }> {
|
|
2068
|
+
const response = await fetch(\`/api/store/api/vc/\${CONNECTION_ID}/customers/resend-verification\`, {
|
|
2069
|
+
method: 'POST',
|
|
2070
|
+
headers: CSRF_HEADERS,
|
|
2071
|
+
});
|
|
2072
|
+
return handleResponse<{ message: string }>(response);
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
/**
|
|
2076
|
+
* Reset password via BFF proxy.
|
|
2077
|
+
* The reset token is in an httpOnly cookie (set by /api/auth/reset-callback when the user
|
|
2078
|
+
* clicked the email link). The proxy reads it server-side \u2014 the token never reaches client JS.
|
|
2079
|
+
*/
|
|
2080
|
+
export async function proxyResetPassword(newPassword: string): Promise<{ message: string }> {
|
|
2081
|
+
const response = await fetch('/api/auth/reset-password', {
|
|
2082
|
+
method: 'POST',
|
|
2083
|
+
headers: CSRF_HEADERS,
|
|
2084
|
+
body: JSON.stringify({ newPassword }),
|
|
2085
|
+
});
|
|
2086
|
+
return handleResponse<{ message: string }>(response);
|
|
1953
2087
|
}
|
|
1954
2088
|
`,
|
|
1955
2089
|
"src/lib/utils.ts": `import { clsx, type ClassValue } from 'clsx';
|
|
@@ -1962,9 +2096,10 @@ export function cn(...inputs: ClassValue[]) {
|
|
|
1962
2096
|
"src/providers/store-provider.tsx.ejs": `'use client';
|
|
1963
2097
|
|
|
1964
2098
|
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
|
|
1965
|
-
import type { StoreInfo, Cart } from 'brainerce';
|
|
2099
|
+
import type { StoreInfo, Cart, CustomerProfile } from 'brainerce';
|
|
1966
2100
|
import { getCartTotals } from 'brainerce';
|
|
1967
|
-
import { getClient, initClient,
|
|
2101
|
+
import { getClient, initClient, setStoredCartId } from '@/lib/brainerce';
|
|
2102
|
+
import { checkAuthStatus, proxyLogout } from '@/lib/auth';
|
|
1968
2103
|
|
|
1969
2104
|
// ---- Store Info Context ----
|
|
1970
2105
|
interface StoreInfoContextValue {
|
|
@@ -1985,17 +2120,17 @@ export function useStoreInfo() {
|
|
|
1985
2120
|
interface AuthContextValue {
|
|
1986
2121
|
isLoggedIn: boolean;
|
|
1987
2122
|
authLoading: boolean;
|
|
1988
|
-
|
|
1989
|
-
login: (
|
|
1990
|
-
logout: () => void
|
|
2123
|
+
customer: CustomerProfile | null;
|
|
2124
|
+
login: () => Promise<void>;
|
|
2125
|
+
logout: () => Promise<void>;
|
|
1991
2126
|
}
|
|
1992
2127
|
|
|
1993
2128
|
const AuthContext = createContext<AuthContextValue>({
|
|
1994
2129
|
isLoggedIn: false,
|
|
1995
2130
|
authLoading: true,
|
|
1996
|
-
|
|
1997
|
-
login: () => {},
|
|
1998
|
-
logout: () => {},
|
|
2131
|
+
customer: null,
|
|
2132
|
+
login: async () => {},
|
|
2133
|
+
logout: async () => {},
|
|
1999
2134
|
});
|
|
2000
2135
|
|
|
2001
2136
|
export function useAuth() {
|
|
@@ -2027,26 +2162,46 @@ export function useCart() {
|
|
|
2027
2162
|
export function StoreProvider({ children }: { children: React.ReactNode }) {
|
|
2028
2163
|
const [storeInfo, setStoreInfo] = useState<StoreInfo | null>(null);
|
|
2029
2164
|
const [storeLoading, setStoreLoading] = useState(true);
|
|
2030
|
-
const [
|
|
2165
|
+
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
|
2166
|
+
const [customer, setCustomer] = useState<CustomerProfile | null>(null);
|
|
2031
2167
|
const [authLoading, setAuthLoading] = useState(true);
|
|
2032
2168
|
const [cart, setCart] = useState<Cart | null>(null);
|
|
2033
2169
|
const [cartLoading, setCartLoading] = useState(true);
|
|
2034
2170
|
|
|
2035
|
-
//
|
|
2171
|
+
// Check auth status via httpOnly cookie (server-side validation)
|
|
2172
|
+
const refreshAuth = useCallback(async () => {
|
|
2173
|
+
try {
|
|
2174
|
+
const status = await checkAuthStatus();
|
|
2175
|
+
setIsLoggedIn(status.isLoggedIn);
|
|
2176
|
+
setCustomer(status.isLoggedIn ? (status.customer as CustomerProfile) : null);
|
|
2177
|
+
} catch {
|
|
2178
|
+
setIsLoggedIn(false);
|
|
2179
|
+
setCustomer(null);
|
|
2180
|
+
} finally {
|
|
2181
|
+
setAuthLoading(false);
|
|
2182
|
+
}
|
|
2183
|
+
}, []);
|
|
2184
|
+
|
|
2185
|
+
// Initialize client, check auth, and fetch store info
|
|
2036
2186
|
useEffect(() => {
|
|
2037
2187
|
const client = initClient();
|
|
2038
|
-
|
|
2039
|
-
if
|
|
2040
|
-
|
|
2188
|
+
|
|
2189
|
+
// Optimistic check: if brainerce_logged_in cookie exists, assume logged in
|
|
2190
|
+
// while we validate the actual token server-side
|
|
2191
|
+
if (typeof document !== 'undefined' && document.cookie.includes('brainerce_logged_in=1')) {
|
|
2192
|
+
setIsLoggedIn(true);
|
|
2041
2193
|
}
|
|
2042
|
-
setAuthLoading(false);
|
|
2043
2194
|
|
|
2195
|
+
// Validate auth token server-side
|
|
2196
|
+
refreshAuth();
|
|
2197
|
+
|
|
2198
|
+
// Fetch store info (public, no auth needed)
|
|
2044
2199
|
client
|
|
2045
2200
|
.getStoreInfo()
|
|
2046
2201
|
.then(setStoreInfo)
|
|
2047
2202
|
.catch(console.error)
|
|
2048
2203
|
.finally(() => setStoreLoading(false));
|
|
2049
|
-
}, []);
|
|
2204
|
+
}, [refreshAuth]);
|
|
2050
2205
|
|
|
2051
2206
|
// Cart management
|
|
2052
2207
|
const refreshCart = useCallback(async () => {
|
|
@@ -2069,24 +2224,26 @@ export function StoreProvider({ children }: { children: React.ReactNode }) {
|
|
|
2069
2224
|
|
|
2070
2225
|
useEffect(() => {
|
|
2071
2226
|
refreshCart();
|
|
2072
|
-
}, [refreshCart,
|
|
2227
|
+
}, [refreshCart, isLoggedIn]);
|
|
2073
2228
|
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
setToken(newToken);
|
|
2229
|
+
// Called after successful login (cookie already set by proxy)
|
|
2230
|
+
const login = useCallback(async () => {
|
|
2231
|
+
// Refresh auth state from server (reads httpOnly cookie)
|
|
2232
|
+
await refreshAuth();
|
|
2079
2233
|
|
|
2080
2234
|
// Merge guest session cart into customer cart
|
|
2235
|
+
const client = getClient();
|
|
2081
2236
|
client.syncCartOnLogin().catch(console.error);
|
|
2082
|
-
}, []);
|
|
2237
|
+
}, [refreshAuth]);
|
|
2238
|
+
|
|
2239
|
+
const logout = useCallback(async () => {
|
|
2240
|
+
// Clear httpOnly cookie server-side
|
|
2241
|
+
await proxyLogout();
|
|
2083
2242
|
|
|
2084
|
-
const logout = useCallback(() => {
|
|
2085
2243
|
const client = getClient();
|
|
2086
|
-
client.clearCustomerToken();
|
|
2087
2244
|
client.onLogout();
|
|
2088
|
-
|
|
2089
|
-
|
|
2245
|
+
setIsLoggedIn(false);
|
|
2246
|
+
setCustomer(null);
|
|
2090
2247
|
setCart(null);
|
|
2091
2248
|
refreshCart();
|
|
2092
2249
|
}, [refreshCart]);
|
|
@@ -2099,7 +2256,7 @@ export function StoreProvider({ children }: { children: React.ReactNode }) {
|
|
|
2099
2256
|
|
|
2100
2257
|
return (
|
|
2101
2258
|
<StoreInfoContext.Provider value={{ storeInfo, loading: storeLoading }}>
|
|
2102
|
-
<AuthContext.Provider value={{ isLoggedIn
|
|
2259
|
+
<AuthContext.Provider value={{ isLoggedIn, authLoading, customer, login, logout }}>
|
|
2103
2260
|
<CartContext.Provider
|
|
2104
2261
|
value={{ cart, cartLoading, refreshCart, itemCount, totals }}
|
|
2105
2262
|
>
|
|
@@ -2248,7 +2405,7 @@ export default function RootLayout({
|
|
|
2248
2405
|
`,
|
|
2249
2406
|
"src/app/products/page.tsx": `'use client';
|
|
2250
2407
|
|
|
2251
|
-
import { Suspense, useEffect, useState, useCallback } from 'react';
|
|
2408
|
+
import { Suspense, useEffect, useState, useCallback, useRef } from 'react';
|
|
2252
2409
|
import { useSearchParams, useRouter } from 'next/navigation';
|
|
2253
2410
|
import type { Product } from 'brainerce';
|
|
2254
2411
|
import type { ProductQueryParams } from 'brainerce';
|
|
@@ -2274,9 +2431,198 @@ const sortOptions: SortOption[] = [
|
|
|
2274
2431
|
{ labelKey: 'sortPriceHigh', sortBy: 'price', sortOrder: 'desc' },
|
|
2275
2432
|
];
|
|
2276
2433
|
|
|
2277
|
-
interface
|
|
2434
|
+
interface CategoryNode {
|
|
2278
2435
|
id: string;
|
|
2279
2436
|
name: string;
|
|
2437
|
+
parentId?: string | null;
|
|
2438
|
+
children: CategoryNode[];
|
|
2439
|
+
}
|
|
2440
|
+
|
|
2441
|
+
/** Collect all descendant IDs (including self) */
|
|
2442
|
+
function getAllDescendantIds(node: CategoryNode): string[] {
|
|
2443
|
+
const ids = [node.id];
|
|
2444
|
+
for (const child of node.children) {
|
|
2445
|
+
ids.push(...getAllDescendantIds(child));
|
|
2446
|
+
}
|
|
2447
|
+
return ids;
|
|
2448
|
+
}
|
|
2449
|
+
|
|
2450
|
+
/** Check if a category or any of its descendants matches the selected ID */
|
|
2451
|
+
function isActiveInTree(node: CategoryNode, selectedId: string): boolean {
|
|
2452
|
+
if (node.id === selectedId) return true;
|
|
2453
|
+
return node.children.some((child) => isActiveInTree(child, selectedId));
|
|
2454
|
+
}
|
|
2455
|
+
|
|
2456
|
+
/** Chevron down SVG */
|
|
2457
|
+
function ChevronDown({ className }: { className?: string }) {
|
|
2458
|
+
return (
|
|
2459
|
+
<svg
|
|
2460
|
+
className={className}
|
|
2461
|
+
width="12"
|
|
2462
|
+
height="12"
|
|
2463
|
+
viewBox="0 0 12 12"
|
|
2464
|
+
fill="none"
|
|
2465
|
+
stroke="currentColor"
|
|
2466
|
+
strokeWidth="2"
|
|
2467
|
+
strokeLinecap="round"
|
|
2468
|
+
strokeLinejoin="round"
|
|
2469
|
+
>
|
|
2470
|
+
<path d="M3 4.5L6 7.5L9 4.5" />
|
|
2471
|
+
</svg>
|
|
2472
|
+
);
|
|
2473
|
+
}
|
|
2474
|
+
|
|
2475
|
+
/** Recursive dropdown items for nested categories */
|
|
2476
|
+
function CategoryDropdownItems({
|
|
2477
|
+
children,
|
|
2478
|
+
depth,
|
|
2479
|
+
selectedId,
|
|
2480
|
+
onSelect,
|
|
2481
|
+
}: {
|
|
2482
|
+
children: CategoryNode[];
|
|
2483
|
+
depth: number;
|
|
2484
|
+
selectedId: string;
|
|
2485
|
+
onSelect: (id: string) => void;
|
|
2486
|
+
}) {
|
|
2487
|
+
return (
|
|
2488
|
+
<>
|
|
2489
|
+
{children.map((child) => (
|
|
2490
|
+
<div key={child.id}>
|
|
2491
|
+
<button
|
|
2492
|
+
onClick={() => onSelect(child.id)}
|
|
2493
|
+
className={cn(
|
|
2494
|
+
'w-full text-start px-4 py-2 text-sm transition-colors hover:bg-muted',
|
|
2495
|
+
selectedId === child.id && 'bg-primary/10 text-primary font-medium'
|
|
2496
|
+
)}
|
|
2497
|
+
style={{ paddingInlineStart: \`\${(depth + 1) * 16}px\` }}
|
|
2498
|
+
>
|
|
2499
|
+
{child.name}
|
|
2500
|
+
</button>
|
|
2501
|
+
{child.children.length > 0 && (
|
|
2502
|
+
<CategoryDropdownItems
|
|
2503
|
+
children={child.children}
|
|
2504
|
+
depth={depth + 1}
|
|
2505
|
+
selectedId={selectedId}
|
|
2506
|
+
onSelect={onSelect}
|
|
2507
|
+
/>
|
|
2508
|
+
)}
|
|
2509
|
+
</div>
|
|
2510
|
+
))}
|
|
2511
|
+
</>
|
|
2512
|
+
);
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2515
|
+
/** Category chip with dropdown for subcategories */
|
|
2516
|
+
function CategoryChip({
|
|
2517
|
+
category,
|
|
2518
|
+
selectedId,
|
|
2519
|
+
onSelect,
|
|
2520
|
+
tc,
|
|
2521
|
+
}: {
|
|
2522
|
+
category: CategoryNode;
|
|
2523
|
+
selectedId: string;
|
|
2524
|
+
onSelect: (id: string) => void;
|
|
2525
|
+
tc: (key: string) => string;
|
|
2526
|
+
}) {
|
|
2527
|
+
const [open, setOpen] = useState(false);
|
|
2528
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
2529
|
+
const hasChildren = category.children.length > 0;
|
|
2530
|
+
const isActive = isActiveInTree(category, selectedId);
|
|
2531
|
+
|
|
2532
|
+
// Find the display name for the selected subcategory
|
|
2533
|
+
function findName(nodes: CategoryNode[], id: string): string | null {
|
|
2534
|
+
for (const n of nodes) {
|
|
2535
|
+
if (n.id === id) return n.name;
|
|
2536
|
+
const found = findName(n.children, id);
|
|
2537
|
+
if (found) return found;
|
|
2538
|
+
}
|
|
2539
|
+
return null;
|
|
2540
|
+
}
|
|
2541
|
+
|
|
2542
|
+
const selectedChildName =
|
|
2543
|
+
isActive && selectedId !== category.id ? findName(category.children, selectedId) : null;
|
|
2544
|
+
|
|
2545
|
+
// Close dropdown on outside click
|
|
2546
|
+
useEffect(() => {
|
|
2547
|
+
if (!open) return;
|
|
2548
|
+
function handleClick(e: MouseEvent) {
|
|
2549
|
+
if (ref.current && !ref.current.contains(e.target as Node)) {
|
|
2550
|
+
setOpen(false);
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
2553
|
+
document.addEventListener('mousedown', handleClick);
|
|
2554
|
+
return () => document.removeEventListener('mousedown', handleClick);
|
|
2555
|
+
}, [open]);
|
|
2556
|
+
|
|
2557
|
+
if (!hasChildren) {
|
|
2558
|
+
return (
|
|
2559
|
+
<button
|
|
2560
|
+
onClick={() => onSelect(category.id)}
|
|
2561
|
+
className={cn(
|
|
2562
|
+
'rounded-full border px-3 py-1.5 text-sm transition-colors',
|
|
2563
|
+
selectedId === category.id
|
|
2564
|
+
? 'bg-primary text-primary-foreground border-primary'
|
|
2565
|
+
: 'border-border text-muted-foreground hover:border-primary hover:text-foreground'
|
|
2566
|
+
)}
|
|
2567
|
+
>
|
|
2568
|
+
{category.name}
|
|
2569
|
+
</button>
|
|
2570
|
+
);
|
|
2571
|
+
}
|
|
2572
|
+
|
|
2573
|
+
return (
|
|
2574
|
+
<div ref={ref} className="relative">
|
|
2575
|
+
<button
|
|
2576
|
+
onClick={() => setOpen((prev) => !prev)}
|
|
2577
|
+
className={cn(
|
|
2578
|
+
'inline-flex items-center gap-1 rounded-full border px-3 py-1.5 text-sm transition-colors',
|
|
2579
|
+
isActive
|
|
2580
|
+
? 'bg-primary text-primary-foreground border-primary'
|
|
2581
|
+
: 'border-border text-muted-foreground hover:border-primary hover:text-foreground'
|
|
2582
|
+
)}
|
|
2583
|
+
>
|
|
2584
|
+
{category.name}
|
|
2585
|
+
{selectedChildName && (
|
|
2586
|
+
<span className="opacity-80">
|
|
2587
|
+
{'\xB7'} {selectedChildName}
|
|
2588
|
+
</span>
|
|
2589
|
+
)}
|
|
2590
|
+
<ChevronDown
|
|
2591
|
+
className={cn('transition-transform ms-0.5', open && 'rotate-180')}
|
|
2592
|
+
/>
|
|
2593
|
+
</button>
|
|
2594
|
+
|
|
2595
|
+
{open && (
|
|
2596
|
+
<div className="bg-background border-border absolute start-0 top-full z-50 mt-1 min-w-[180px] overflow-hidden rounded-lg border shadow-lg">
|
|
2597
|
+
{/* "All in [category]" option */}
|
|
2598
|
+
<button
|
|
2599
|
+
onClick={() => {
|
|
2600
|
+
onSelect(category.id);
|
|
2601
|
+
setOpen(false);
|
|
2602
|
+
}}
|
|
2603
|
+
className={cn(
|
|
2604
|
+
'w-full text-start px-4 py-2 text-sm font-medium transition-colors hover:bg-muted',
|
|
2605
|
+
selectedId === category.id && 'bg-primary/10 text-primary'
|
|
2606
|
+
)}
|
|
2607
|
+
>
|
|
2608
|
+
{tc('all')} {category.name}
|
|
2609
|
+
</button>
|
|
2610
|
+
<div className="bg-border mx-2 h-px" />
|
|
2611
|
+
{/* Recursive children */}
|
|
2612
|
+
<div
|
|
2613
|
+
onClick={() => setOpen(false)}
|
|
2614
|
+
>
|
|
2615
|
+
<CategoryDropdownItems
|
|
2616
|
+
children={category.children}
|
|
2617
|
+
depth={0}
|
|
2618
|
+
selectedId={selectedId}
|
|
2619
|
+
onSelect={onSelect}
|
|
2620
|
+
/>
|
|
2621
|
+
</div>
|
|
2622
|
+
</div>
|
|
2623
|
+
)}
|
|
2624
|
+
</div>
|
|
2625
|
+
);
|
|
2280
2626
|
}
|
|
2281
2627
|
|
|
2282
2628
|
function ProductsContent() {
|
|
@@ -2295,18 +2641,18 @@ function ProductsContent() {
|
|
|
2295
2641
|
const [page, setPage] = useState(1);
|
|
2296
2642
|
const [totalPages, setTotalPages] = useState(1);
|
|
2297
2643
|
const [total, setTotal] = useState(0);
|
|
2298
|
-
const [categories, setCategories] = useState<
|
|
2644
|
+
const [categories, setCategories] = useState<CategoryNode[]>([]);
|
|
2299
2645
|
|
|
2300
2646
|
const sortIndex = parseInt(sortParam, 10) || 0;
|
|
2301
2647
|
const currentSort = sortOptions[sortIndex] || sortOptions[0];
|
|
2302
2648
|
|
|
2303
|
-
// Load categories
|
|
2649
|
+
// Load categories (keep tree structure)
|
|
2304
2650
|
useEffect(() => {
|
|
2305
2651
|
async function loadCategories() {
|
|
2306
2652
|
try {
|
|
2307
2653
|
const client = getClient();
|
|
2308
2654
|
const result = await client.getCategories();
|
|
2309
|
-
setCategories(result.categories
|
|
2655
|
+
setCategories(result.categories as CategoryNode[]);
|
|
2310
2656
|
} catch {
|
|
2311
2657
|
// Categories endpoint may not be available in all modes
|
|
2312
2658
|
}
|
|
@@ -2375,6 +2721,10 @@ function ProductsContent() {
|
|
|
2375
2721
|
router.push(\`/products?\${params.toString()}\`);
|
|
2376
2722
|
}
|
|
2377
2723
|
|
|
2724
|
+
function handleCategorySelect(id: string) {
|
|
2725
|
+
updateParam('category', id);
|
|
2726
|
+
}
|
|
2727
|
+
|
|
2378
2728
|
return (
|
|
2379
2729
|
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
|
2380
2730
|
{/* Page Header */}
|
|
@@ -2406,18 +2756,13 @@ function ProductsContent() {
|
|
|
2406
2756
|
{tc('all')}
|
|
2407
2757
|
</button>
|
|
2408
2758
|
{categories.map((cat) => (
|
|
2409
|
-
<
|
|
2759
|
+
<CategoryChip
|
|
2410
2760
|
key={cat.id}
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
: 'border-border text-muted-foreground hover:border-primary hover:text-foreground'
|
|
2417
|
-
)}
|
|
2418
|
-
>
|
|
2419
|
-
{cat.name}
|
|
2420
|
-
</button>
|
|
2761
|
+
category={cat}
|
|
2762
|
+
selectedId={categoryId}
|
|
2763
|
+
onSelect={handleCategorySelect}
|
|
2764
|
+
tc={tc as (key: string) => string}
|
|
2765
|
+
/>
|
|
2421
2766
|
))}
|
|
2422
2767
|
</div>
|
|
2423
2768
|
)}
|
|
@@ -2499,7 +2844,18 @@ import { useEffect, useState, useMemo } from 'react';
|
|
|
2499
2844
|
import { useParams } from 'next/navigation';
|
|
2500
2845
|
import Image from 'next/image';
|
|
2501
2846
|
import Link from 'next/link';
|
|
2502
|
-
import type {
|
|
2847
|
+
import type {
|
|
2848
|
+
Product,
|
|
2849
|
+
ProductVariant,
|
|
2850
|
+
ProductImage,
|
|
2851
|
+
ProductMetafield,
|
|
2852
|
+
ProductRecommendationsResponse,
|
|
2853
|
+
DownloadFile,
|
|
2854
|
+
} from 'brainerce';
|
|
2855
|
+
|
|
2856
|
+
type ProductWithRecommendations = Product & {
|
|
2857
|
+
recommendations?: ProductRecommendationsResponse;
|
|
2858
|
+
};
|
|
2503
2859
|
import { getProductPriceInfo, getDescriptionContent } from 'brainerce';
|
|
2504
2860
|
import { getClient } from '@/lib/brainerce';
|
|
2505
2861
|
import { useCart } from '@/providers/store-provider';
|
|
@@ -2507,6 +2863,7 @@ import { PriceDisplay } from '@/components/shared/price-display';
|
|
|
2507
2863
|
import { LoadingSpinner } from '@/components/shared/loading-spinner';
|
|
2508
2864
|
import { VariantSelector } from '@/components/products/variant-selector';
|
|
2509
2865
|
import { StockBadge } from '@/components/products/stock-badge';
|
|
2866
|
+
import { RecommendationSection } from '@/components/products/recommendation-section';
|
|
2510
2867
|
import { useTranslations } from '@/lib/translations';
|
|
2511
2868
|
import { cn } from '@/lib/utils';
|
|
2512
2869
|
|
|
@@ -2600,7 +2957,7 @@ export default function ProductDetailPage() {
|
|
|
2600
2957
|
const { refreshCart } = useCart();
|
|
2601
2958
|
const t = useTranslations('productDetail');
|
|
2602
2959
|
const tc = useTranslations('common');
|
|
2603
|
-
const [product, setProduct] = useState<
|
|
2960
|
+
const [product, setProduct] = useState<ProductWithRecommendations | null>(null);
|
|
2604
2961
|
const [loading, setLoading] = useState(true);
|
|
2605
2962
|
const [error, setError] = useState<string | null>(null);
|
|
2606
2963
|
const [selectedVariant, setSelectedVariant] = useState<ProductVariant | null>(null);
|
|
@@ -2609,6 +2966,8 @@ export default function ProductDetailPage() {
|
|
|
2609
2966
|
const [addingToCart, setAddingToCart] = useState(false);
|
|
2610
2967
|
const [addedMessage, setAddedMessage] = useState(false);
|
|
2611
2968
|
|
|
2969
|
+
const recommendations = product?.recommendations ?? null;
|
|
2970
|
+
|
|
2612
2971
|
// Load product
|
|
2613
2972
|
useEffect(() => {
|
|
2614
2973
|
async function load() {
|
|
@@ -2617,7 +2976,7 @@ export default function ProductDetailPage() {
|
|
|
2617
2976
|
setError(null);
|
|
2618
2977
|
const client = getClient();
|
|
2619
2978
|
const p = await client.getProductBySlug(slug);
|
|
2620
|
-
setProduct(p);
|
|
2979
|
+
setProduct(p as ProductWithRecommendations);
|
|
2621
2980
|
|
|
2622
2981
|
// Auto-select first variant
|
|
2623
2982
|
if (p.variants && p.variants.length > 0) {
|
|
@@ -2823,8 +3182,43 @@ export default function ProductDetailPage() {
|
|
|
2823
3182
|
size="lg"
|
|
2824
3183
|
/>
|
|
2825
3184
|
|
|
2826
|
-
{/* Stock */}
|
|
2827
|
-
|
|
3185
|
+
{/* Stock / Digital badge */}
|
|
3186
|
+
{product.isDownloadable ? (
|
|
3187
|
+
<span className="inline-flex items-center gap-1.5 rounded-full bg-green-100 px-3 py-1 text-xs font-medium text-green-800 dark:bg-green-950/30 dark:text-green-400">
|
|
3188
|
+
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
3189
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
|
3190
|
+
</svg>
|
|
3191
|
+
{t('instantDownload')}
|
|
3192
|
+
</span>
|
|
3193
|
+
) : (
|
|
3194
|
+
<StockBadge inventory={inventory} lowStockThreshold={5} />
|
|
3195
|
+
)}
|
|
3196
|
+
|
|
3197
|
+
{/* Downloadable files info */}
|
|
3198
|
+
{product.isDownloadable && product.downloads && product.downloads.length > 0 && (
|
|
3199
|
+
<div className="bg-muted/50 rounded-lg border p-4">
|
|
3200
|
+
<p className="text-foreground mb-2 text-sm font-medium">
|
|
3201
|
+
{t('filesIncluded', { count: product.downloads.length })}
|
|
3202
|
+
</p>
|
|
3203
|
+
<ul className="space-y-1.5">
|
|
3204
|
+
{product.downloads.map((file: DownloadFile) => (
|
|
3205
|
+
<li key={file.id} className="text-muted-foreground flex items-center gap-2 text-sm">
|
|
3206
|
+
<svg className="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
3207
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
|
3208
|
+
</svg>
|
|
3209
|
+
<span className="truncate">{file.name}</span>
|
|
3210
|
+
{file.size && (
|
|
3211
|
+
<span className="flex-shrink-0 text-xs">
|
|
3212
|
+
({file.size < 1024 * 1024
|
|
3213
|
+
? \`\${(file.size / 1024).toFixed(0)} KB\`
|
|
3214
|
+
: \`\${(file.size / (1024 * 1024)).toFixed(1)} MB\`})
|
|
3215
|
+
</span>
|
|
3216
|
+
)}
|
|
3217
|
+
</li>
|
|
3218
|
+
))}
|
|
3219
|
+
</ul>
|
|
3220
|
+
</div>
|
|
3221
|
+
)}
|
|
2828
3222
|
|
|
2829
3223
|
{/* Variant Selector */}
|
|
2830
3224
|
{product.type === 'VARIABLE' && product.variants && product.variants.length > 0 && (
|
|
@@ -2888,6 +3282,13 @@ export default function ProductDetailPage() {
|
|
|
2888
3282
|
</button>
|
|
2889
3283
|
</div>
|
|
2890
3284
|
|
|
3285
|
+
{/* Download after purchase note */}
|
|
3286
|
+
{product.isDownloadable && (
|
|
3287
|
+
<p className="text-muted-foreground text-sm">
|
|
3288
|
+
{t('downloadAfterPurchase')}
|
|
3289
|
+
</p>
|
|
3290
|
+
)}
|
|
3291
|
+
|
|
2891
3292
|
{/* Description */}
|
|
2892
3293
|
{description && (
|
|
2893
3294
|
<div className="border-border border-t pt-4">
|
|
@@ -2925,19 +3326,41 @@ export default function ProductDetailPage() {
|
|
|
2925
3326
|
)}
|
|
2926
3327
|
</div>
|
|
2927
3328
|
</div>
|
|
3329
|
+
|
|
3330
|
+
{/* Upsells \u2014 premium alternatives (product page) */}
|
|
3331
|
+
{recommendations?.upsells && recommendations.upsells.length > 0 && (
|
|
3332
|
+
<RecommendationSection
|
|
3333
|
+
title={t('upgradeYourChoice')}
|
|
3334
|
+
items={recommendations.upsells}
|
|
3335
|
+
className="mt-12"
|
|
3336
|
+
/>
|
|
3337
|
+
)}
|
|
3338
|
+
|
|
3339
|
+
{/* Related products \u2014 similar items (bottom of product page) */}
|
|
3340
|
+
{recommendations?.related && recommendations.related.length > 0 && (
|
|
3341
|
+
<RecommendationSection
|
|
3342
|
+
title={t('similarProducts')}
|
|
3343
|
+
items={recommendations.related}
|
|
3344
|
+
className="mt-12"
|
|
3345
|
+
/>
|
|
3346
|
+
)}
|
|
2928
3347
|
</div>
|
|
2929
3348
|
);
|
|
2930
3349
|
}
|
|
2931
3350
|
`,
|
|
2932
3351
|
"src/app/cart/page.tsx": `'use client';
|
|
2933
3352
|
|
|
3353
|
+
import { useEffect, useState } from 'react';
|
|
2934
3354
|
import Link from 'next/link';
|
|
3355
|
+
import type { CartRecommendationsResponse } from 'brainerce';
|
|
3356
|
+
import { getClient } from '@/lib/brainerce';
|
|
2935
3357
|
import { useCart } from '@/providers/store-provider';
|
|
2936
3358
|
import { CartItem } from '@/components/cart/cart-item';
|
|
2937
3359
|
import { CartSummary } from '@/components/cart/cart-summary';
|
|
2938
3360
|
import { CouponInput } from '@/components/cart/coupon-input';
|
|
2939
3361
|
import { CartNudges } from '@/components/cart/cart-nudges';
|
|
2940
3362
|
import { ReservationCountdown } from '@/components/cart/reservation-countdown';
|
|
3363
|
+
import { CartRecommendationSection } from '@/components/products/recommendation-section';
|
|
2941
3364
|
import { LoadingSpinner } from '@/components/shared/loading-spinner';
|
|
2942
3365
|
import { useTranslations } from '@/lib/translations';
|
|
2943
3366
|
|
|
@@ -2945,6 +3368,17 @@ export default function CartPage() {
|
|
|
2945
3368
|
const { cart, cartLoading, refreshCart, itemCount } = useCart();
|
|
2946
3369
|
const t = useTranslations('cart');
|
|
2947
3370
|
const tc = useTranslations('common');
|
|
3371
|
+
const [cartRecs, setCartRecs] = useState<CartRecommendationsResponse | null>(null);
|
|
3372
|
+
|
|
3373
|
+
// Load cross-sell recommendations when cart changes
|
|
3374
|
+
useEffect(() => {
|
|
3375
|
+
if (!cart?.id || cart.items.length === 0) {
|
|
3376
|
+
setCartRecs(null);
|
|
3377
|
+
return;
|
|
3378
|
+
}
|
|
3379
|
+
const client = getClient();
|
|
3380
|
+
client.getCartRecommendations(cart.id, 4).then(setCartRecs).catch(() => {});
|
|
3381
|
+
}, [cart?.id, cart?.items.length]);
|
|
2948
3382
|
|
|
2949
3383
|
if (cartLoading) {
|
|
2950
3384
|
return (
|
|
@@ -3036,6 +3470,15 @@ export default function CartPage() {
|
|
|
3036
3470
|
</div>
|
|
3037
3471
|
</div>
|
|
3038
3472
|
</div>
|
|
3473
|
+
|
|
3474
|
+
{/* Cross-sell recommendations */}
|
|
3475
|
+
{cartRecs?.recommendations && cartRecs.recommendations.length > 0 && (
|
|
3476
|
+
<CartRecommendationSection
|
|
3477
|
+
title={t('youMightAlsoNeed')}
|
|
3478
|
+
items={cartRecs.recommendations}
|
|
3479
|
+
className="mt-10"
|
|
3480
|
+
/>
|
|
3481
|
+
)}
|
|
3039
3482
|
</div>
|
|
3040
3483
|
);
|
|
3041
3484
|
}
|
|
@@ -3919,8 +4362,8 @@ export default function OrderConfirmationPage() {
|
|
|
3919
4362
|
import { useState } from 'react';
|
|
3920
4363
|
import { useRouter } from 'next/navigation';
|
|
3921
4364
|
import Link from 'next/link';
|
|
3922
|
-
import { getClient } from '@/lib/brainerce';
|
|
3923
4365
|
import { useAuth } from '@/providers/store-provider';
|
|
4366
|
+
import { proxyLogin } from '@/lib/auth';
|
|
3924
4367
|
import { LoginForm } from '@/components/auth/login-form';
|
|
3925
4368
|
import { OAuthButtons } from '@/components/auth/oauth-buttons';
|
|
3926
4369
|
import { useTranslations } from '@/lib/translations';
|
|
@@ -3934,15 +4377,16 @@ export default function LoginPage() {
|
|
|
3934
4377
|
async function handleLogin(email: string, password: string) {
|
|
3935
4378
|
try {
|
|
3936
4379
|
setError(null);
|
|
3937
|
-
const
|
|
3938
|
-
const result = await client.loginCustomer(email, password);
|
|
4380
|
+
const result = await proxyLogin(email, password);
|
|
3939
4381
|
|
|
3940
4382
|
if (result.requiresVerification) {
|
|
3941
|
-
|
|
4383
|
+
// Verification token is NOT the auth JWT \u2014 safe to pass in URL
|
|
4384
|
+
router.push('/verify-email');
|
|
3942
4385
|
return;
|
|
3943
4386
|
}
|
|
3944
4387
|
|
|
3945
|
-
auth
|
|
4388
|
+
// Cookie was set by the proxy; refresh auth state
|
|
4389
|
+
await auth.login();
|
|
3946
4390
|
router.push('/');
|
|
3947
4391
|
} catch (err) {
|
|
3948
4392
|
const message = err instanceof Error ? err.message : 'Login failed. Please try again.';
|
|
@@ -3978,8 +4422,8 @@ export default function LoginPage() {
|
|
|
3978
4422
|
import { useState } from 'react';
|
|
3979
4423
|
import { useRouter } from 'next/navigation';
|
|
3980
4424
|
import Link from 'next/link';
|
|
3981
|
-
import { getClient } from '@/lib/brainerce';
|
|
3982
4425
|
import { useAuth } from '@/providers/store-provider';
|
|
4426
|
+
import { proxyRegister } from '@/lib/auth';
|
|
3983
4427
|
import { RegisterForm } from '@/components/auth/register-form';
|
|
3984
4428
|
import { OAuthButtons } from '@/components/auth/oauth-buttons';
|
|
3985
4429
|
import { useTranslations } from '@/lib/translations';
|
|
@@ -3998,20 +4442,16 @@ export default function RegisterPage() {
|
|
|
3998
4442
|
}) {
|
|
3999
4443
|
try {
|
|
4000
4444
|
setError(null);
|
|
4001
|
-
const
|
|
4002
|
-
const result = await client.registerCustomer({
|
|
4003
|
-
firstName: data.firstName,
|
|
4004
|
-
lastName: data.lastName,
|
|
4005
|
-
email: data.email,
|
|
4006
|
-
password: data.password,
|
|
4007
|
-
});
|
|
4445
|
+
const result = await proxyRegister(data);
|
|
4008
4446
|
|
|
4009
4447
|
if (result.requiresVerification) {
|
|
4010
|
-
|
|
4448
|
+
// Cookie already set by proxy; verify-email uses it for auth
|
|
4449
|
+
router.push('/verify-email');
|
|
4011
4450
|
return;
|
|
4012
4451
|
}
|
|
4013
4452
|
|
|
4014
|
-
auth
|
|
4453
|
+
// Cookie was set by the proxy; refresh auth state
|
|
4454
|
+
await auth.login();
|
|
4015
4455
|
router.push('/');
|
|
4016
4456
|
} catch (err) {
|
|
4017
4457
|
const message = err instanceof Error ? err.message : 'Registration failed. Please try again.';
|
|
@@ -4045,10 +4485,10 @@ export default function RegisterPage() {
|
|
|
4045
4485
|
"src/app/verify-email/page.tsx": `'use client';
|
|
4046
4486
|
|
|
4047
4487
|
import { Suspense, useState, useRef, useEffect, useCallback } from 'react';
|
|
4048
|
-
import { useRouter
|
|
4488
|
+
import { useRouter } from 'next/navigation';
|
|
4049
4489
|
import Link from 'next/link';
|
|
4050
|
-
import { getClient } from '@/lib/brainerce';
|
|
4051
4490
|
import { useAuth } from '@/providers/store-provider';
|
|
4491
|
+
import { proxyVerifyEmail, proxyResendVerification } from '@/lib/auth';
|
|
4052
4492
|
import { LoadingSpinner } from '@/components/shared/loading-spinner';
|
|
4053
4493
|
import { useTranslations } from '@/lib/translations';
|
|
4054
4494
|
|
|
@@ -4057,10 +4497,8 @@ const RESEND_COOLDOWN_SECONDS = 60;
|
|
|
4057
4497
|
|
|
4058
4498
|
function VerifyEmailContent() {
|
|
4059
4499
|
const router = useRouter();
|
|
4060
|
-
const searchParams = useSearchParams();
|
|
4061
4500
|
const auth = useAuth();
|
|
4062
4501
|
|
|
4063
|
-
const token = searchParams.get('token');
|
|
4064
4502
|
const t = useTranslations('auth');
|
|
4065
4503
|
|
|
4066
4504
|
const [digits, setDigits] = useState<string[]>(Array(CODE_LENGTH).fill(''));
|
|
@@ -4088,18 +4526,17 @@ function VerifyEmailContent() {
|
|
|
4088
4526
|
|
|
4089
4527
|
const handleSubmit = useCallback(
|
|
4090
4528
|
async (code: string) => {
|
|
4091
|
-
if (
|
|
4529
|
+
if (code.length !== CODE_LENGTH || loading) return;
|
|
4092
4530
|
|
|
4093
4531
|
try {
|
|
4094
4532
|
setLoading(true);
|
|
4095
4533
|
setError(null);
|
|
4096
|
-
|
|
4097
|
-
const result = await
|
|
4534
|
+
// Auth token is in httpOnly cookie \u2014 proxy adds Authorization header
|
|
4535
|
+
const result = await proxyVerifyEmail(code);
|
|
4098
4536
|
|
|
4099
4537
|
if (result.verified) {
|
|
4100
|
-
//
|
|
4101
|
-
|
|
4102
|
-
auth.login(authToken);
|
|
4538
|
+
// Refresh auth state (cookie already set)
|
|
4539
|
+
await auth.login();
|
|
4103
4540
|
setSuccess('Email verified successfully! Redirecting...');
|
|
4104
4541
|
setTimeout(() => router.push('/'), 1500);
|
|
4105
4542
|
} else {
|
|
@@ -4113,7 +4550,7 @@ function VerifyEmailContent() {
|
|
|
4113
4550
|
setLoading(false);
|
|
4114
4551
|
}
|
|
4115
4552
|
},
|
|
4116
|
-
[
|
|
4553
|
+
[loading, auth, router]
|
|
4117
4554
|
);
|
|
4118
4555
|
|
|
4119
4556
|
function handleDigitChange(index: number, value: string) {
|
|
@@ -4167,13 +4604,12 @@ function VerifyEmailContent() {
|
|
|
4167
4604
|
}
|
|
4168
4605
|
|
|
4169
4606
|
async function handleResend() {
|
|
4170
|
-
if (
|
|
4607
|
+
if (resending || cooldown > 0) return;
|
|
4171
4608
|
|
|
4172
4609
|
try {
|
|
4173
4610
|
setResending(true);
|
|
4174
4611
|
setError(null);
|
|
4175
|
-
|
|
4176
|
-
await client.resendVerificationEmail(token);
|
|
4612
|
+
await proxyResendVerification();
|
|
4177
4613
|
setSuccess('Verification code sent! Check your email.');
|
|
4178
4614
|
setCooldown(RESEND_COOLDOWN_SECONDS);
|
|
4179
4615
|
// Clear digits for fresh entry
|
|
@@ -4193,37 +4629,6 @@ function VerifyEmailContent() {
|
|
|
4193
4629
|
handleSubmit(code);
|
|
4194
4630
|
}
|
|
4195
4631
|
|
|
4196
|
-
// No token provided
|
|
4197
|
-
if (!token) {
|
|
4198
|
-
return (
|
|
4199
|
-
<div className="flex min-h-[60vh] items-center justify-center px-4 py-12">
|
|
4200
|
-
<div className="w-full max-w-md space-y-4 text-center">
|
|
4201
|
-
<svg
|
|
4202
|
-
className="text-muted-foreground mx-auto h-12 w-12"
|
|
4203
|
-
fill="none"
|
|
4204
|
-
viewBox="0 0 24 24"
|
|
4205
|
-
stroke="currentColor"
|
|
4206
|
-
>
|
|
4207
|
-
<path
|
|
4208
|
-
strokeLinecap="round"
|
|
4209
|
-
strokeLinejoin="round"
|
|
4210
|
-
strokeWidth={1.5}
|
|
4211
|
-
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
|
4212
|
-
/>
|
|
4213
|
-
</svg>
|
|
4214
|
-
<h1 className="text-foreground text-2xl font-bold">{t('verificationInvalid')}</h1>
|
|
4215
|
-
<p className="text-muted-foreground text-sm">{t('verificationInvalidDesc')}</p>
|
|
4216
|
-
<Link
|
|
4217
|
-
href="/register"
|
|
4218
|
-
className="bg-primary text-primary-foreground inline-flex items-center rounded px-6 py-3 font-medium transition-opacity hover:opacity-90"
|
|
4219
|
-
>
|
|
4220
|
-
{t('goToRegister')}
|
|
4221
|
-
</Link>
|
|
4222
|
-
</div>
|
|
4223
|
-
</div>
|
|
4224
|
-
);
|
|
4225
|
-
}
|
|
4226
|
-
|
|
4227
4632
|
return (
|
|
4228
4633
|
<div className="flex min-h-[60vh] items-center justify-center px-4 py-12">
|
|
4229
4634
|
<div className="w-full max-w-md space-y-6">
|
|
@@ -4353,8 +4758,8 @@ function OAuthCallbackContent() {
|
|
|
4353
4758
|
const t = useTranslations('auth');
|
|
4354
4759
|
|
|
4355
4760
|
const oauthSuccess = searchParams.get('oauth_success');
|
|
4356
|
-
const token = searchParams.get('token');
|
|
4357
4761
|
const oauthError = searchParams.get('oauth_error');
|
|
4762
|
+
// Token is no longer in URL \u2014 it was set as httpOnly cookie by /api/auth/oauth-callback
|
|
4358
4763
|
|
|
4359
4764
|
useEffect(() => {
|
|
4360
4765
|
// Prevent double-processing in React StrictMode
|
|
@@ -4366,13 +4771,15 @@ function OAuthCallbackContent() {
|
|
|
4366
4771
|
return;
|
|
4367
4772
|
}
|
|
4368
4773
|
|
|
4369
|
-
if (oauthSuccess === 'true'
|
|
4370
|
-
auth
|
|
4371
|
-
|
|
4774
|
+
if (oauthSuccess === 'true') {
|
|
4775
|
+
// Cookie was already set by the API route; refresh auth state
|
|
4776
|
+
auth.login().then(() => {
|
|
4777
|
+
router.push('/');
|
|
4778
|
+
});
|
|
4372
4779
|
} else {
|
|
4373
4780
|
setError(t('authFailedDesc'));
|
|
4374
4781
|
}
|
|
4375
|
-
}, [oauthSuccess,
|
|
4782
|
+
}, [oauthSuccess, oauthError, auth, router, t]);
|
|
4376
4783
|
|
|
4377
4784
|
if (error) {
|
|
4378
4785
|
return (
|
|
@@ -4539,6 +4946,354 @@ export default function AccountPage() {
|
|
|
4539
4946
|
</div>
|
|
4540
4947
|
);
|
|
4541
4948
|
}
|
|
4949
|
+
`,
|
|
4950
|
+
"src/app/api/store/[...path]/route.ts": `import { NextRequest, NextResponse } from 'next/server';
|
|
4951
|
+
import { cookies } from 'next/headers';
|
|
4952
|
+
|
|
4953
|
+
const BACKEND_URL = (process.env.BRAINERCE_API_URL || 'https://api.brainerce.com').replace(
|
|
4954
|
+
/\\/$/,
|
|
4955
|
+
''
|
|
4956
|
+
);
|
|
4957
|
+
|
|
4958
|
+
const TOKEN_COOKIE = 'brainerce_customer_token';
|
|
4959
|
+
const LOGGED_IN_COOKIE = 'brainerce_logged_in';
|
|
4960
|
+
const CSRF_HEADER = 'x-requested-with';
|
|
4961
|
+
const CSRF_VALUE = 'brainerce';
|
|
4962
|
+
|
|
4963
|
+
const COOKIE_MAX_AGE = 7 * 24 * 60 * 60; // 7 days
|
|
4964
|
+
|
|
4965
|
+
/** Auth endpoints whose responses contain tokens to intercept */
|
|
4966
|
+
const AUTH_ENDPOINTS = ['customers/login', 'customers/register', 'customers/verify-email'];
|
|
4967
|
+
|
|
4968
|
+
function isAuthEndpoint(path: string): boolean {
|
|
4969
|
+
return AUTH_ENDPOINTS.some((ep) => path.endsWith(ep));
|
|
4970
|
+
}
|
|
4971
|
+
|
|
4972
|
+
function isSecure(): boolean {
|
|
4973
|
+
return process.env.NODE_ENV === 'production';
|
|
4974
|
+
}
|
|
4975
|
+
|
|
4976
|
+
function setAuthCookies(response: NextResponse, token: string): void {
|
|
4977
|
+
response.cookies.set(TOKEN_COOKIE, token, {
|
|
4978
|
+
httpOnly: true,
|
|
4979
|
+
secure: isSecure(),
|
|
4980
|
+
sameSite: 'lax',
|
|
4981
|
+
path: '/',
|
|
4982
|
+
maxAge: COOKIE_MAX_AGE,
|
|
4983
|
+
});
|
|
4984
|
+
response.cookies.set(LOGGED_IN_COOKIE, '1', {
|
|
4985
|
+
httpOnly: false,
|
|
4986
|
+
secure: isSecure(),
|
|
4987
|
+
sameSite: 'lax',
|
|
4988
|
+
path: '/',
|
|
4989
|
+
maxAge: COOKIE_MAX_AGE,
|
|
4990
|
+
});
|
|
4991
|
+
}
|
|
4992
|
+
|
|
4993
|
+
function clearAuthCookies(response: NextResponse): void {
|
|
4994
|
+
response.cookies.delete(TOKEN_COOKIE);
|
|
4995
|
+
response.cookies.delete(LOGGED_IN_COOKIE);
|
|
4996
|
+
}
|
|
4997
|
+
|
|
4998
|
+
async function proxyRequest(
|
|
4999
|
+
request: NextRequest,
|
|
5000
|
+
params: { path: string[] }
|
|
5001
|
+
): Promise<NextResponse> {
|
|
5002
|
+
const method = request.method;
|
|
5003
|
+
|
|
5004
|
+
// CSRF protection for mutating requests
|
|
5005
|
+
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
|
|
5006
|
+
const csrfHeader = request.headers.get(CSRF_HEADER);
|
|
5007
|
+
if (csrfHeader !== CSRF_VALUE) {
|
|
5008
|
+
return NextResponse.json({ error: 'CSRF validation failed' }, { status: 403 });
|
|
5009
|
+
}
|
|
5010
|
+
}
|
|
5011
|
+
|
|
5012
|
+
// Build backend URL from path segments
|
|
5013
|
+
const pathSegments = params.path.join('/');
|
|
5014
|
+
const backendUrl = new URL(\`\${BACKEND_URL}/\${pathSegments}\`);
|
|
5015
|
+
|
|
5016
|
+
// Forward query parameters
|
|
5017
|
+
request.nextUrl.searchParams.forEach((value, key) => {
|
|
5018
|
+
backendUrl.searchParams.set(key, value);
|
|
5019
|
+
});
|
|
5020
|
+
|
|
5021
|
+
// Build headers for backend request
|
|
5022
|
+
const headers: Record<string, string> = {
|
|
5023
|
+
'Content-Type': 'application/json',
|
|
5024
|
+
};
|
|
5025
|
+
|
|
5026
|
+
// Forward Origin/Referer so backend BrowserOriginGuard accepts proxied requests
|
|
5027
|
+
const origin = request.headers.get('origin');
|
|
5028
|
+
const referer = request.headers.get('referer');
|
|
5029
|
+
if (origin) headers['Origin'] = origin;
|
|
5030
|
+
if (referer) headers['Referer'] = referer;
|
|
5031
|
+
|
|
5032
|
+
// Forward SDK version header if present
|
|
5033
|
+
const sdkVersion = request.headers.get('x-sdk-version');
|
|
5034
|
+
if (sdkVersion) {
|
|
5035
|
+
headers['X-SDK-Version'] = sdkVersion;
|
|
5036
|
+
}
|
|
5037
|
+
|
|
5038
|
+
// Add auth token from httpOnly cookie
|
|
5039
|
+
const cookieStore = await cookies();
|
|
5040
|
+
const tokenCookie = cookieStore.get(TOKEN_COOKIE);
|
|
5041
|
+
if (tokenCookie?.value) {
|
|
5042
|
+
headers['Authorization'] = \`Bearer \${tokenCookie.value}\`;
|
|
5043
|
+
}
|
|
5044
|
+
|
|
5045
|
+
// Forward request body for non-GET requests
|
|
5046
|
+
let body: string | undefined;
|
|
5047
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
5048
|
+
try {
|
|
5049
|
+
body = await request.text();
|
|
5050
|
+
} catch {
|
|
5051
|
+
// No body
|
|
5052
|
+
}
|
|
5053
|
+
}
|
|
5054
|
+
|
|
5055
|
+
// Proxy the request to backend
|
|
5056
|
+
let backendResponse: Response;
|
|
5057
|
+
try {
|
|
5058
|
+
backendResponse = await fetch(backendUrl.toString(), {
|
|
5059
|
+
method,
|
|
5060
|
+
headers,
|
|
5061
|
+
body,
|
|
5062
|
+
});
|
|
5063
|
+
} catch (error) {
|
|
5064
|
+
return NextResponse.json({ error: 'Backend service unavailable' }, { status: 502 });
|
|
5065
|
+
}
|
|
5066
|
+
|
|
5067
|
+
// Read response body
|
|
5068
|
+
const responseText = await backendResponse.text();
|
|
5069
|
+
|
|
5070
|
+
// For auth endpoints: intercept token, set cookie, strip token from response
|
|
5071
|
+
if (backendResponse.ok && method === 'POST' && isAuthEndpoint(pathSegments)) {
|
|
5072
|
+
try {
|
|
5073
|
+
const data = JSON.parse(responseText);
|
|
5074
|
+
if (data.token) {
|
|
5075
|
+
const token = data.token;
|
|
5076
|
+
|
|
5077
|
+
// Strip token from client response
|
|
5078
|
+
const { token: _stripped, ...safeData } = data;
|
|
5079
|
+
|
|
5080
|
+
const response = NextResponse.json(safeData, {
|
|
5081
|
+
status: backendResponse.status,
|
|
5082
|
+
});
|
|
5083
|
+
setAuthCookies(response, token);
|
|
5084
|
+
return response;
|
|
5085
|
+
}
|
|
5086
|
+
} catch {
|
|
5087
|
+
// Not JSON or no token field \u2014 pass through
|
|
5088
|
+
}
|
|
5089
|
+
}
|
|
5090
|
+
|
|
5091
|
+
// Handle 401 responses: clear auth cookies
|
|
5092
|
+
if (backendResponse.status === 401 && tokenCookie?.value) {
|
|
5093
|
+
const response = new NextResponse(responseText, {
|
|
5094
|
+
status: backendResponse.status,
|
|
5095
|
+
headers: {
|
|
5096
|
+
'Content-Type': backendResponse.headers.get('Content-Type') || 'application/json',
|
|
5097
|
+
},
|
|
5098
|
+
});
|
|
5099
|
+
clearAuthCookies(response);
|
|
5100
|
+
return response;
|
|
5101
|
+
}
|
|
5102
|
+
|
|
5103
|
+
// Pass through response as-is
|
|
5104
|
+
return new NextResponse(responseText, {
|
|
5105
|
+
status: backendResponse.status,
|
|
5106
|
+
headers: {
|
|
5107
|
+
'Content-Type': backendResponse.headers.get('Content-Type') || 'application/json',
|
|
5108
|
+
},
|
|
5109
|
+
});
|
|
5110
|
+
}
|
|
5111
|
+
|
|
5112
|
+
export async function GET(
|
|
5113
|
+
request: NextRequest,
|
|
5114
|
+
{ params }: { params: Promise<{ path: string[] }> }
|
|
5115
|
+
) {
|
|
5116
|
+
return proxyRequest(request, await params);
|
|
5117
|
+
}
|
|
5118
|
+
|
|
5119
|
+
export async function POST(
|
|
5120
|
+
request: NextRequest,
|
|
5121
|
+
{ params }: { params: Promise<{ path: string[] }> }
|
|
5122
|
+
) {
|
|
5123
|
+
return proxyRequest(request, await params);
|
|
5124
|
+
}
|
|
5125
|
+
|
|
5126
|
+
export async function PUT(
|
|
5127
|
+
request: NextRequest,
|
|
5128
|
+
{ params }: { params: Promise<{ path: string[] }> }
|
|
5129
|
+
) {
|
|
5130
|
+
return proxyRequest(request, await params);
|
|
5131
|
+
}
|
|
5132
|
+
|
|
5133
|
+
export async function PATCH(
|
|
5134
|
+
request: NextRequest,
|
|
5135
|
+
{ params }: { params: Promise<{ path: string[] }> }
|
|
5136
|
+
) {
|
|
5137
|
+
return proxyRequest(request, await params);
|
|
5138
|
+
}
|
|
5139
|
+
|
|
5140
|
+
export async function DELETE(
|
|
5141
|
+
request: NextRequest,
|
|
5142
|
+
{ params }: { params: Promise<{ path: string[] }> }
|
|
5143
|
+
) {
|
|
5144
|
+
return proxyRequest(request, await params);
|
|
5145
|
+
}
|
|
5146
|
+
`,
|
|
5147
|
+
"src/app/api/auth/oauth-callback/route.ts": `import { NextRequest, NextResponse } from 'next/server';
|
|
5148
|
+
|
|
5149
|
+
const TOKEN_COOKIE = 'brainerce_customer_token';
|
|
5150
|
+
const LOGGED_IN_COOKIE = 'brainerce_logged_in';
|
|
5151
|
+
const COOKIE_MAX_AGE = 7 * 24 * 60 * 60; // 7 days
|
|
5152
|
+
|
|
5153
|
+
function isSecure(): boolean {
|
|
5154
|
+
return process.env.NODE_ENV === 'production';
|
|
5155
|
+
}
|
|
5156
|
+
|
|
5157
|
+
/**
|
|
5158
|
+
* OAuth callback handler.
|
|
5159
|
+
* The backend redirects here with ?token=jwt&oauth_success=true after OAuth code exchange.
|
|
5160
|
+
* We set the httpOnly cookie and redirect to the client-side callback page (without the token).
|
|
5161
|
+
*/
|
|
5162
|
+
export async function GET(request: NextRequest) {
|
|
5163
|
+
const { searchParams } = request.nextUrl;
|
|
5164
|
+
const token = searchParams.get('token');
|
|
5165
|
+
const oauthSuccess = searchParams.get('oauth_success');
|
|
5166
|
+
const oauthError = searchParams.get('oauth_error');
|
|
5167
|
+
|
|
5168
|
+
// Build redirect URL to client-side callback page
|
|
5169
|
+
const redirectUrl = new URL('/auth/callback', request.url);
|
|
5170
|
+
|
|
5171
|
+
if (oauthError) {
|
|
5172
|
+
redirectUrl.searchParams.set('oauth_error', oauthError);
|
|
5173
|
+
return NextResponse.redirect(redirectUrl);
|
|
5174
|
+
}
|
|
5175
|
+
|
|
5176
|
+
if (oauthSuccess === 'true' && token) {
|
|
5177
|
+
redirectUrl.searchParams.set('oauth_success', 'true');
|
|
5178
|
+
|
|
5179
|
+
const response = NextResponse.redirect(redirectUrl);
|
|
5180
|
+
|
|
5181
|
+
// Set httpOnly cookie with the token
|
|
5182
|
+
response.cookies.set(TOKEN_COOKIE, token, {
|
|
5183
|
+
httpOnly: true,
|
|
5184
|
+
secure: isSecure(),
|
|
5185
|
+
sameSite: 'lax',
|
|
5186
|
+
path: '/',
|
|
5187
|
+
maxAge: COOKIE_MAX_AGE,
|
|
5188
|
+
});
|
|
5189
|
+
|
|
5190
|
+
// Set indicator cookie (readable by client JS)
|
|
5191
|
+
response.cookies.set(LOGGED_IN_COOKIE, '1', {
|
|
5192
|
+
httpOnly: false,
|
|
5193
|
+
secure: isSecure(),
|
|
5194
|
+
sameSite: 'lax',
|
|
5195
|
+
path: '/',
|
|
5196
|
+
maxAge: COOKIE_MAX_AGE,
|
|
5197
|
+
});
|
|
5198
|
+
|
|
5199
|
+
return response;
|
|
5200
|
+
}
|
|
5201
|
+
|
|
5202
|
+
// Fallback: no token or success flag
|
|
5203
|
+
redirectUrl.searchParams.set('oauth_error', 'Authentication failed');
|
|
5204
|
+
return NextResponse.redirect(redirectUrl);
|
|
5205
|
+
}
|
|
5206
|
+
`,
|
|
5207
|
+
"src/app/api/auth/me/route.ts": `import { NextResponse } from 'next/server';
|
|
5208
|
+
import { cookies } from 'next/headers';
|
|
5209
|
+
|
|
5210
|
+
const BACKEND_URL = (process.env.BRAINERCE_API_URL || 'https://api.brainerce.com').replace(
|
|
5211
|
+
/\\/$/,
|
|
5212
|
+
''
|
|
5213
|
+
);
|
|
5214
|
+
|
|
5215
|
+
const CONNECTION_ID = process.env.NEXT_PUBLIC_BRAINERCE_CONNECTION_ID || '';
|
|
5216
|
+
|
|
5217
|
+
const TOKEN_COOKIE = 'brainerce_customer_token';
|
|
5218
|
+
const LOGGED_IN_COOKIE = 'brainerce_logged_in';
|
|
5219
|
+
|
|
5220
|
+
/**
|
|
5221
|
+
* Auth status check endpoint.
|
|
5222
|
+
* Reads the httpOnly cookie, validates against backend, returns auth state.
|
|
5223
|
+
*/
|
|
5224
|
+
export async function GET() {
|
|
5225
|
+
const cookieStore = await cookies();
|
|
5226
|
+
const tokenCookie = cookieStore.get(TOKEN_COOKIE);
|
|
5227
|
+
|
|
5228
|
+
if (!tokenCookie?.value) {
|
|
5229
|
+
return NextResponse.json({ isLoggedIn: false });
|
|
5230
|
+
}
|
|
5231
|
+
|
|
5232
|
+
try {
|
|
5233
|
+
// Validate token by calling backend profile endpoint
|
|
5234
|
+
const response = await fetch(\`\${BACKEND_URL}/api/vc/\${CONNECTION_ID}/customers/me\`, {
|
|
5235
|
+
headers: {
|
|
5236
|
+
Authorization: \`Bearer \${tokenCookie.value}\`,
|
|
5237
|
+
'Content-Type': 'application/json',
|
|
5238
|
+
},
|
|
5239
|
+
});
|
|
5240
|
+
|
|
5241
|
+
if (!response.ok) {
|
|
5242
|
+
// Token is invalid or expired \u2014 clear cookies
|
|
5243
|
+
const res = NextResponse.json({ isLoggedIn: false });
|
|
5244
|
+
res.cookies.delete(TOKEN_COOKIE);
|
|
5245
|
+
res.cookies.delete(LOGGED_IN_COOKIE);
|
|
5246
|
+
return res;
|
|
5247
|
+
}
|
|
5248
|
+
|
|
5249
|
+
const customer = await response.json();
|
|
5250
|
+
return NextResponse.json({ isLoggedIn: true, customer });
|
|
5251
|
+
} catch {
|
|
5252
|
+
// Backend unreachable \u2014 don't clear cookies, might be temporary
|
|
5253
|
+
return NextResponse.json({ isLoggedIn: false, error: 'Service unavailable' }, { status: 503 });
|
|
5254
|
+
}
|
|
5255
|
+
}
|
|
5256
|
+
`,
|
|
5257
|
+
"src/app/api/auth/logout/route.ts": `import { NextResponse } from 'next/server';
|
|
5258
|
+
|
|
5259
|
+
const TOKEN_COOKIE = 'brainerce_customer_token';
|
|
5260
|
+
const LOGGED_IN_COOKIE = 'brainerce_logged_in';
|
|
5261
|
+
|
|
5262
|
+
/**
|
|
5263
|
+
* Logout endpoint. Clears auth cookies.
|
|
5264
|
+
*/
|
|
5265
|
+
export async function POST() {
|
|
5266
|
+
const response = NextResponse.json({ success: true });
|
|
5267
|
+
response.cookies.delete(TOKEN_COOKIE);
|
|
5268
|
+
response.cookies.delete(LOGGED_IN_COOKIE);
|
|
5269
|
+
return response;
|
|
5270
|
+
}
|
|
5271
|
+
`,
|
|
5272
|
+
"src/middleware.ts": `import { NextRequest, NextResponse } from 'next/server';
|
|
5273
|
+
|
|
5274
|
+
const TOKEN_COOKIE = 'brainerce_customer_token';
|
|
5275
|
+
|
|
5276
|
+
/** Routes that require customer authentication */
|
|
5277
|
+
const PROTECTED_PATHS = ['/account'];
|
|
5278
|
+
|
|
5279
|
+
export function middleware(request: NextRequest) {
|
|
5280
|
+
const { pathname } = request.nextUrl;
|
|
5281
|
+
const isProtected = PROTECTED_PATHS.some((p) => pathname.startsWith(p));
|
|
5282
|
+
|
|
5283
|
+
if (isProtected) {
|
|
5284
|
+
const token = request.cookies.get(TOKEN_COOKIE);
|
|
5285
|
+
if (!token?.value) {
|
|
5286
|
+
const loginUrl = new URL('/login', request.url);
|
|
5287
|
+
return NextResponse.redirect(loginUrl);
|
|
5288
|
+
}
|
|
5289
|
+
}
|
|
5290
|
+
|
|
5291
|
+
return NextResponse.next();
|
|
5292
|
+
}
|
|
5293
|
+
|
|
5294
|
+
export const config = {
|
|
5295
|
+
matcher: ['/account/:path*'],
|
|
5296
|
+
};
|
|
4542
5297
|
`,
|
|
4543
5298
|
"src/components/products/product-card.tsx": `'use client';
|
|
4544
5299
|
|
|
@@ -4559,6 +5314,7 @@ interface ProductCardProps {
|
|
|
4559
5314
|
|
|
4560
5315
|
export function ProductCard({ product, className }: ProductCardProps) {
|
|
4561
5316
|
const t = useTranslations('common');
|
|
5317
|
+
const tp = useTranslations('productDetail');
|
|
4562
5318
|
const { price, originalPrice, isOnSale } = getProductPriceInfo(product);
|
|
4563
5319
|
const mainImage = product.images?.[0];
|
|
4564
5320
|
const imageUrl = mainImage?.url || null;
|
|
@@ -4603,6 +5359,11 @@ export function ProductCard({ product, className }: ProductCardProps) {
|
|
|
4603
5359
|
</span>
|
|
4604
5360
|
)}
|
|
4605
5361
|
<DiscountBadge discount={product.discount} />
|
|
5362
|
+
{product.isDownloadable && (
|
|
5363
|
+
<span className="bg-primary text-primary-foreground rounded px-2 py-1 text-xs font-bold">
|
|
5364
|
+
{tp('digitalProduct')}
|
|
5365
|
+
</span>
|
|
5366
|
+
)}
|
|
4606
5367
|
</div>
|
|
4607
5368
|
</div>
|
|
4608
5369
|
|
|
@@ -5870,8 +6631,11 @@ declare global {
|
|
|
5870
6631
|
}
|
|
5871
6632
|
|
|
5872
6633
|
// Payment SDK script URLs \u2014 resolved per provider
|
|
6634
|
+
// SRI hashes must be regenerated when the provider updates their SDK
|
|
5873
6635
|
const PAYMENT_SDK_URL = 'https://cdn.meshulam.co.il/sdk/gs.min.js';
|
|
6636
|
+
const PAYMENT_SDK_SRI = 'sha384-e1OYzZERZLgbR8Zw1I4Ww/J7qXGyEsZb7LF0z5W/sbnTsFwtxop7sKZsLGNp6CB1';
|
|
5874
6637
|
const APPLE_PAY_SDK_URL = 'https://meshulam.co.il/_media/js/apple_pay_sdk/sdk.min.js';
|
|
6638
|
+
const APPLE_PAY_SDK_SRI = 'sha384-CG67HXmWBeyuRR/jqFsYr6dGEMFyuZw3/hRmcyACJRYHGb/1ebEkQZyzdJXDc9/u';
|
|
5875
6639
|
|
|
5876
6640
|
interface PaymentStepProps {
|
|
5877
6641
|
checkoutId: string;
|
|
@@ -5882,7 +6646,7 @@ interface PaymentStepProps {
|
|
|
5882
6646
|
* Load a script tag if not already present in the DOM.
|
|
5883
6647
|
* Resolves when the script loads (or immediately if already present).
|
|
5884
6648
|
*/
|
|
5885
|
-
function loadScript(src: string, optional = false): Promise<void> {
|
|
6649
|
+
function loadScript(src: string, optional = false, integrity?: string): Promise<void> {
|
|
5886
6650
|
return new Promise((resolve) => {
|
|
5887
6651
|
if (document.querySelector(\`script[src="\${src}"]\`)) {
|
|
5888
6652
|
resolve();
|
|
@@ -5891,6 +6655,10 @@ function loadScript(src: string, optional = false): Promise<void> {
|
|
|
5891
6655
|
const script = document.createElement('script');
|
|
5892
6656
|
script.src = src;
|
|
5893
6657
|
script.async = true;
|
|
6658
|
+
if (integrity) {
|
|
6659
|
+
script.integrity = integrity;
|
|
6660
|
+
script.crossOrigin = 'anonymous';
|
|
6661
|
+
}
|
|
5894
6662
|
script.onload = () => resolve();
|
|
5895
6663
|
script.onerror = () => {
|
|
5896
6664
|
if (optional) {
|
|
@@ -5983,10 +6751,10 @@ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
|
|
|
5983
6751
|
|
|
5984
6752
|
async function loadSdkScripts() {
|
|
5985
6753
|
// Load Apple Pay SDK (optional)
|
|
5986
|
-
await loadScript(APPLE_PAY_SDK_URL, true);
|
|
6754
|
+
await loadScript(APPLE_PAY_SDK_URL, true, APPLE_PAY_SDK_SRI);
|
|
5987
6755
|
|
|
5988
6756
|
// Load payment SDK script
|
|
5989
|
-
await loadScript(PAYMENT_SDK_URL);
|
|
6757
|
+
await loadScript(PAYMENT_SDK_URL, false, PAYMENT_SDK_SRI);
|
|
5990
6758
|
|
|
5991
6759
|
// Wait for SDK global to be set by the script
|
|
5992
6760
|
const available = await waitForPaymentSdkGlobal();
|
|
@@ -6649,7 +7417,7 @@ export function OAuthButtons({ className }: OAuthButtonsProps) {
|
|
|
6649
7417
|
try {
|
|
6650
7418
|
setRedirecting(provider);
|
|
6651
7419
|
const client = getClient();
|
|
6652
|
-
const redirectUrl = window.location.origin + '/auth/callback';
|
|
7420
|
+
const redirectUrl = window.location.origin + '/api/auth/oauth-callback';
|
|
6653
7421
|
const result = await client.getOAuthAuthorizeUrl(provider, { redirectUrl });
|
|
6654
7422
|
window.location.href = result.authorizationUrl;
|
|
6655
7423
|
} catch (err) {
|
|
@@ -6931,10 +7699,11 @@ export function ProfileSection({ profile, onProfileUpdate, className }: ProfileS
|
|
|
6931
7699
|
`,
|
|
6932
7700
|
"src/components/account/order-history.tsx": `'use client';
|
|
6933
7701
|
|
|
6934
|
-
import { useState } from 'react';
|
|
7702
|
+
import { useState, useEffect } from 'react';
|
|
6935
7703
|
import Image from 'next/image';
|
|
6936
|
-
import type { Order, OrderStatus } from 'brainerce';
|
|
7704
|
+
import type { Order, OrderStatus, OrderDownloadLink } from 'brainerce';
|
|
6937
7705
|
import { formatPrice } from 'brainerce';
|
|
7706
|
+
import { getClient } from '@/lib/brainerce';
|
|
6938
7707
|
import { useTranslations } from '@/lib/translations';
|
|
6939
7708
|
import { cn } from '@/lib/utils';
|
|
6940
7709
|
|
|
@@ -7118,6 +7887,11 @@ function OrderCard({ order }: { order: Order }) {
|
|
|
7118
7887
|
</div>
|
|
7119
7888
|
))}
|
|
7120
7889
|
|
|
7890
|
+
{/* Downloads section */}
|
|
7891
|
+
{order.hasDownloads && (
|
|
7892
|
+
<OrderDownloads orderId={order.id} />
|
|
7893
|
+
)}
|
|
7894
|
+
|
|
7121
7895
|
<OrderFinancialSummary order={order} currency={currency} />
|
|
7122
7896
|
</div>
|
|
7123
7897
|
)}
|
|
@@ -7125,6 +7899,71 @@ function OrderCard({ order }: { order: Order }) {
|
|
|
7125
7899
|
);
|
|
7126
7900
|
}
|
|
7127
7901
|
|
|
7902
|
+
function OrderDownloads({ orderId }: { orderId: string }) {
|
|
7903
|
+
const t = useTranslations('account');
|
|
7904
|
+
const [downloads, setDownloads] = useState<OrderDownloadLink[] | null>(null);
|
|
7905
|
+
const [loading, setLoading] = useState(true);
|
|
7906
|
+
|
|
7907
|
+
useEffect(() => {
|
|
7908
|
+
let cancelled = false;
|
|
7909
|
+
async function fetch() {
|
|
7910
|
+
try {
|
|
7911
|
+
const client = getClient();
|
|
7912
|
+
const links = await client.getOrderDownloads(orderId);
|
|
7913
|
+
if (!cancelled) setDownloads(links);
|
|
7914
|
+
} catch {
|
|
7915
|
+
if (!cancelled) setDownloads([]);
|
|
7916
|
+
} finally {
|
|
7917
|
+
if (!cancelled) setLoading(false);
|
|
7918
|
+
}
|
|
7919
|
+
}
|
|
7920
|
+
fetch();
|
|
7921
|
+
return () => { cancelled = true; };
|
|
7922
|
+
}, [orderId]);
|
|
7923
|
+
|
|
7924
|
+
if (loading) {
|
|
7925
|
+
return (
|
|
7926
|
+
<div className="border-border border-t pt-2">
|
|
7927
|
+
<p className="text-muted-foreground animate-pulse text-xs">{t('downloads')}...</p>
|
|
7928
|
+
</div>
|
|
7929
|
+
);
|
|
7930
|
+
}
|
|
7931
|
+
|
|
7932
|
+
if (!downloads || downloads.length === 0) return null;
|
|
7933
|
+
|
|
7934
|
+
return (
|
|
7935
|
+
<div className="border-border space-y-2 border-t pt-2">
|
|
7936
|
+
<p className="text-foreground text-sm font-medium">{t('downloads')}</p>
|
|
7937
|
+
{downloads.map((link, idx) => (
|
|
7938
|
+
<div key={idx} className="flex items-center gap-3">
|
|
7939
|
+
<div className="min-w-0 flex-1">
|
|
7940
|
+
<p className="text-foreground truncate text-sm">{link.fileName}</p>
|
|
7941
|
+
<p className="text-muted-foreground text-xs">
|
|
7942
|
+
{link.productName}
|
|
7943
|
+
{' \xB7 '}
|
|
7944
|
+
{link.downloadLimit != null
|
|
7945
|
+
? t('downloadsRemaining', { used: link.downloadsUsed, limit: link.downloadLimit })
|
|
7946
|
+
: t('unlimitedDownloads')}
|
|
7947
|
+
{' \xB7 '}
|
|
7948
|
+
{link.expiresAt
|
|
7949
|
+
? t('expiresAt', { date: new Date(link.expiresAt).toLocaleDateString() })
|
|
7950
|
+
: t('noExpiry')}
|
|
7951
|
+
</p>
|
|
7952
|
+
</div>
|
|
7953
|
+
<a
|
|
7954
|
+
href={link.downloadUrl}
|
|
7955
|
+
target="_blank"
|
|
7956
|
+
rel="noopener noreferrer"
|
|
7957
|
+
className="bg-primary text-primary-foreground flex-shrink-0 rounded px-3 py-1 text-xs font-medium hover:opacity-90"
|
|
7958
|
+
>
|
|
7959
|
+
{t('downloadFile')}
|
|
7960
|
+
</a>
|
|
7961
|
+
</div>
|
|
7962
|
+
))}
|
|
7963
|
+
</div>
|
|
7964
|
+
);
|
|
7965
|
+
}
|
|
7966
|
+
|
|
7128
7967
|
function OrderFinancialSummary({ order, currency }: { order: Order; currency: string }) {
|
|
7129
7968
|
const tc = useTranslations('common');
|
|
7130
7969
|
const totalAmount = order.totalAmount || order.total || '0';
|
|
@@ -8006,7 +8845,7 @@ var DETAILED = `# Required Pages \u2014 Detailed Guide
|
|
|
8006
8845
|
- Stock badge
|
|
8007
8846
|
- Discount badge (\`getProductDiscountBadge()\`)
|
|
8008
8847
|
- Add to cart button (disabled when \`!canPurchase\`)
|
|
8009
|
-
- Product recommendations (\`
|
|
8848
|
+
- Product recommendations (from \`product.recommendations\` \u2014 embedded in product response)
|
|
8010
8849
|
- Use \`client.getProductBySlug(slug)\`
|
|
8011
8850
|
|
|
8012
8851
|
## 4. Cart (\`/cart\`)
|
|
@@ -8284,6 +9123,39 @@ my-store/
|
|
|
8284
9123
|
| \`app/auth/callback/page.tsx\` | Extracts OAuth token from URL params, stores in localStorage |
|
|
8285
9124
|
| \`app/account/page.tsx\` | Protected page: getMyProfile() + getMyOrders() |
|
|
8286
9125
|
| \`components/Header.tsx\` | Site header with nav, cart count badge, search autocomplete |
|
|
9126
|
+
|
|
9127
|
+
## Content Security Policy (CSP)
|
|
9128
|
+
|
|
9129
|
+
Payment SDKs load iframes and scripts from external domains. Add these CSP headers in \`next.config.js\`:
|
|
9130
|
+
|
|
9131
|
+
\`\`\`js
|
|
9132
|
+
// next.config.js
|
|
9133
|
+
module.exports = {
|
|
9134
|
+
async headers() {
|
|
9135
|
+
return [{
|
|
9136
|
+
source: '/(.*)',
|
|
9137
|
+
headers: [{
|
|
9138
|
+
key: 'Content-Security-Policy',
|
|
9139
|
+
value: [
|
|
9140
|
+
"default-src 'self'",
|
|
9141
|
+
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.meshulam.co.il https://meshulam.co.il https://*.meshulam.co.il https://grow.link https://*.grow.link https://*.grow.security https://js.stripe.com https://pay.google.com",
|
|
9142
|
+
"style-src 'self' 'unsafe-inline'",
|
|
9143
|
+
"img-src 'self' data: blob: https:",
|
|
9144
|
+
"font-src 'self' data:",
|
|
9145
|
+
"frame-src 'self' https://*.meshulam.co.il https://grow.link https://*.grow.link https://*.grow.security https://*.creditguard.co.il https://js.stripe.com https://hooks.stripe.com https://pay.google.com",
|
|
9146
|
+
"connect-src 'self' https://*.meshulam.co.il https://grow.link https://*.grow.link https://*.grow.security https://google.com https://pay.google.com https://*.stripe.com https://*.creditguard.co.il",
|
|
9147
|
+
"worker-src 'self' blob:",
|
|
9148
|
+
].join('; '),
|
|
9149
|
+
}],
|
|
9150
|
+
}];
|
|
9151
|
+
},
|
|
9152
|
+
};
|
|
9153
|
+
\`\`\`
|
|
9154
|
+
|
|
9155
|
+
**Required domains by provider:**
|
|
9156
|
+
- **Grow**: \`*.meshulam.co.il\`, \`grow.link\`, \`*.grow.link\`, \`*.grow.security\`, \`*.creditguard.co.il\`
|
|
9157
|
+
- **Stripe**: \`js.stripe.com\`, \`hooks.stripe.com\`, \`*.stripe.com\`
|
|
9158
|
+
- **Google Pay**: \`pay.google.com\`, \`google.com\`
|
|
8287
9159
|
`;
|
|
8288
9160
|
|
|
8289
9161
|
// src/resources/project-template.ts
|