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