@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/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
|
|
735
|
-
const
|
|
736
|
-
|
|
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
|
|
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:
|
|
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
|
-
//
|
|
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
|
|
1950
|
+
// Initialize client (no token hydration \u2014 auth handled by httpOnly cookie)
|
|
1961
1951
|
export function initClient(): BrainerceClient {
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
2004
|
-
login: (
|
|
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
|
-
|
|
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 [
|
|
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
|
-
//
|
|
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
|
-
|
|
2054
|
-
if
|
|
2055
|
-
|
|
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,
|
|
2242
|
+
}, [refreshCart, isLoggedIn]);
|
|
2088
2243
|
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
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
|
-
|
|
2104
|
-
|
|
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
|
|
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
|
|
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<
|
|
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
|
|
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
|
-
<
|
|
2774
|
+
<CategoryChip
|
|
2425
2775
|
key={cat.id}
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
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 {
|
|
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<
|
|
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
|
-
|
|
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
|
|
3953
|
-
const result = await client.loginCustomer(email, password);
|
|
4395
|
+
const result = await proxyLogin(email, password);
|
|
3954
4396
|
|
|
3955
4397
|
if (result.requiresVerification) {
|
|
3956
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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 (
|
|
4544
|
+
if (code.length !== CODE_LENGTH || loading) return;
|
|
4107
4545
|
|
|
4108
4546
|
try {
|
|
4109
4547
|
setLoading(true);
|
|
4110
4548
|
setError(null);
|
|
4111
|
-
|
|
4112
|
-
const result = await
|
|
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
|
-
//
|
|
4116
|
-
|
|
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
|
-
[
|
|
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 (
|
|
4622
|
+
if (resending || cooldown > 0) return;
|
|
4186
4623
|
|
|
4187
4624
|
try {
|
|
4188
4625
|
setResending(true);
|
|
4189
4626
|
setError(null);
|
|
4190
|
-
|
|
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'
|
|
4385
|
-
auth
|
|
4386
|
-
|
|
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,
|
|
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 (\`
|
|
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
|