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