create-brainerce-store 1.28.17 → 1.28.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +1 -1
- package/package.json +46 -46
- package/templates/nextjs/base/src/app/api/store/[...path]/route.ts +235 -235
- package/templates/nextjs/base/src/app/cart/page.tsx +8 -34
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +73 -73
- package/templates/nextjs/base/src/app/products/page.tsx +475 -475
- package/templates/nextjs/base/src/lib/navigation.tsx.ejs +17 -6
package/dist/index.js
CHANGED
|
@@ -31,7 +31,7 @@ var require_package = __commonJS({
|
|
|
31
31
|
"package.json"(exports2, module2) {
|
|
32
32
|
module2.exports = {
|
|
33
33
|
name: "create-brainerce-store",
|
|
34
|
-
version: "1.28.
|
|
34
|
+
version: "1.28.19",
|
|
35
35
|
description: "Scaffold a production-ready e-commerce storefront connected to Brainerce",
|
|
36
36
|
bin: {
|
|
37
37
|
"create-brainerce-store": "dist/index.js"
|
package/package.json
CHANGED
|
@@ -1,46 +1,46 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "create-brainerce-store",
|
|
3
|
-
"version": "1.28.
|
|
4
|
-
"description": "Scaffold a production-ready e-commerce storefront connected to Brainerce",
|
|
5
|
-
"bin": {
|
|
6
|
-
"create-brainerce-store": "dist/index.js"
|
|
7
|
-
},
|
|
8
|
-
"files": [
|
|
9
|
-
"dist",
|
|
10
|
-
"templates",
|
|
11
|
-
"messages"
|
|
12
|
-
],
|
|
13
|
-
"scripts": {
|
|
14
|
-
"build": "tsup src/index.ts --format cjs --dts --clean",
|
|
15
|
-
"dev": "tsup src/index.ts --format cjs --dts --watch",
|
|
16
|
-
"clean": "rimraf dist",
|
|
17
|
-
"prepublishOnly": "npm run build"
|
|
18
|
-
},
|
|
19
|
-
"dependencies": {
|
|
20
|
-
"chalk": "^4.1.2",
|
|
21
|
-
"commander": "^12.1.0",
|
|
22
|
-
"ejs": "^3.1.10",
|
|
23
|
-
"fs-extra": "^11.2.0",
|
|
24
|
-
"ora": "^5.4.1",
|
|
25
|
-
"prompts": "^2.4.2"
|
|
26
|
-
},
|
|
27
|
-
"devDependencies": {
|
|
28
|
-
"@types/ejs": "^3.1.5",
|
|
29
|
-
"@types/fs-extra": "^11.0.4",
|
|
30
|
-
"@types/prompts": "^2.4.9",
|
|
31
|
-
"tsup": "^8.0.0",
|
|
32
|
-
"typescript": "^5.4.0"
|
|
33
|
-
},
|
|
34
|
-
"engines": {
|
|
35
|
-
"node": ">=18"
|
|
36
|
-
},
|
|
37
|
-
"keywords": [
|
|
38
|
-
"brainerce",
|
|
39
|
-
"ecommerce",
|
|
40
|
-
"storefront",
|
|
41
|
-
"scaffold",
|
|
42
|
-
"create",
|
|
43
|
-
"nextjs"
|
|
44
|
-
],
|
|
45
|
-
"license": "MIT"
|
|
46
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "create-brainerce-store",
|
|
3
|
+
"version": "1.28.19",
|
|
4
|
+
"description": "Scaffold a production-ready e-commerce storefront connected to Brainerce",
|
|
5
|
+
"bin": {
|
|
6
|
+
"create-brainerce-store": "dist/index.js"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"dist",
|
|
10
|
+
"templates",
|
|
11
|
+
"messages"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsup src/index.ts --format cjs --dts --clean",
|
|
15
|
+
"dev": "tsup src/index.ts --format cjs --dts --watch",
|
|
16
|
+
"clean": "rimraf dist",
|
|
17
|
+
"prepublishOnly": "npm run build"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"chalk": "^4.1.2",
|
|
21
|
+
"commander": "^12.1.0",
|
|
22
|
+
"ejs": "^3.1.10",
|
|
23
|
+
"fs-extra": "^11.2.0",
|
|
24
|
+
"ora": "^5.4.1",
|
|
25
|
+
"prompts": "^2.4.2"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/ejs": "^3.1.5",
|
|
29
|
+
"@types/fs-extra": "^11.0.4",
|
|
30
|
+
"@types/prompts": "^2.4.9",
|
|
31
|
+
"tsup": "^8.0.0",
|
|
32
|
+
"typescript": "^5.4.0"
|
|
33
|
+
},
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=18"
|
|
36
|
+
},
|
|
37
|
+
"keywords": [
|
|
38
|
+
"brainerce",
|
|
39
|
+
"ecommerce",
|
|
40
|
+
"storefront",
|
|
41
|
+
"scaffold",
|
|
42
|
+
"create",
|
|
43
|
+
"nextjs"
|
|
44
|
+
],
|
|
45
|
+
"license": "MIT"
|
|
46
|
+
}
|
|
@@ -1,235 +1,235 @@
|
|
|
1
|
-
// SECURITY: This BFF proxy intentionally has no application-level rate limiting.
|
|
2
|
-
// Rate limiting is the deployer's responsibility — configure it at the platform
|
|
3
|
-
// edge (Vercel Firewall, Cloudflare, nginx) or add a Redis-backed limiter
|
|
4
|
-
// (e.g. @upstash/ratelimit) here before going to production. Auth endpoints
|
|
5
|
-
// like customers/login and customers/register are the most important to cover.
|
|
6
|
-
import { NextRequest, NextResponse } from 'next/server';
|
|
7
|
-
import { cookies } from 'next/headers';
|
|
8
|
-
import { checkCsrf } from '@/lib/csrf';
|
|
9
|
-
|
|
10
|
-
const BACKEND_URL = (process.env.BRAINERCE_API_URL || 'https://api.brainerce.com').replace(
|
|
11
|
-
/\/$/,
|
|
12
|
-
''
|
|
13
|
-
);
|
|
14
|
-
|
|
15
|
-
const TOKEN_COOKIE = 'brainerce_customer_token';
|
|
16
|
-
const LOGGED_IN_COOKIE = 'brainerce_logged_in';
|
|
17
|
-
|
|
18
|
-
const COOKIE_MAX_AGE = 7 * 24 * 60 * 60; // 7 days
|
|
19
|
-
const BACKEND_TIMEOUT_MS = 15_000;
|
|
20
|
-
|
|
21
|
-
/** Auth endpoints whose responses contain tokens to intercept */
|
|
22
|
-
const AUTH_ENDPOINTS = ['customers/login', 'customers/register', 'customers/verify-email'];
|
|
23
|
-
|
|
24
|
-
function isAuthEndpoint(path: string): boolean {
|
|
25
|
-
return AUTH_ENDPOINTS.some((ep) => path.endsWith(ep));
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function isSafePathSegment(segment: string): boolean {
|
|
29
|
-
if (!segment) return false;
|
|
30
|
-
if (segment === '.' || segment === '..') return false;
|
|
31
|
-
if (segment.includes('/') || segment.includes('\\')) return false;
|
|
32
|
-
if (segment.includes('\0')) return false;
|
|
33
|
-
return true;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function isSecure(): boolean {
|
|
37
|
-
return process.env.NODE_ENV === 'production';
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function setAuthCookies(response: NextResponse, token: string): void {
|
|
41
|
-
response.cookies.set(TOKEN_COOKIE, token, {
|
|
42
|
-
httpOnly: true,
|
|
43
|
-
secure: isSecure(),
|
|
44
|
-
sameSite: 'lax',
|
|
45
|
-
path: '/',
|
|
46
|
-
maxAge: COOKIE_MAX_AGE,
|
|
47
|
-
});
|
|
48
|
-
response.cookies.set(LOGGED_IN_COOKIE, '1', {
|
|
49
|
-
httpOnly: false,
|
|
50
|
-
secure: isSecure(),
|
|
51
|
-
sameSite: 'lax',
|
|
52
|
-
path: '/',
|
|
53
|
-
maxAge: COOKIE_MAX_AGE,
|
|
54
|
-
});
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function clearAuthCookies(response: NextResponse): void {
|
|
58
|
-
response.cookies.delete(TOKEN_COOKIE);
|
|
59
|
-
response.cookies.delete(LOGGED_IN_COOKIE);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
async function proxyRequest(
|
|
63
|
-
request: NextRequest,
|
|
64
|
-
params: { path: string[] }
|
|
65
|
-
): Promise<NextResponse> {
|
|
66
|
-
const method = request.method;
|
|
67
|
-
|
|
68
|
-
// Reject path-traversal attempts before constructing the backend URL
|
|
69
|
-
if (!params.path.every(isSafePathSegment)) {
|
|
70
|
-
return NextResponse.json({ error: 'Invalid path' }, { status: 400 });
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// CSRF protection for mutating requests
|
|
74
|
-
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
|
|
75
|
-
const csrfError = checkCsrf(request);
|
|
76
|
-
if (csrfError) return csrfError;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// Build backend URL from path segments
|
|
80
|
-
const pathSegments = params.path.join('/');
|
|
81
|
-
const backendUrl = new URL(`${BACKEND_URL}/${pathSegments}`);
|
|
82
|
-
|
|
83
|
-
// Forward query parameters
|
|
84
|
-
request.nextUrl.searchParams.forEach((value, key) => {
|
|
85
|
-
backendUrl.searchParams.set(key, value);
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
// Build headers for backend request
|
|
89
|
-
const headers: Record<string, string> = {
|
|
90
|
-
'Content-Type': 'application/json',
|
|
91
|
-
};
|
|
92
|
-
|
|
93
|
-
// Send the proxy's own origin (not the client-supplied Origin header).
|
|
94
|
-
// The backend's BrowserOriginGuard only checks for presence of Origin/Referer,
|
|
95
|
-
// so forwarding a client-controlled value adds spoofing surface for nothing.
|
|
96
|
-
headers['Origin'] = request.nextUrl.origin;
|
|
97
|
-
|
|
98
|
-
// Forward SDK version header if present
|
|
99
|
-
const sdkVersion = request.headers.get('x-sdk-version');
|
|
100
|
-
if (sdkVersion) {
|
|
101
|
-
headers['X-SDK-Version'] = sdkVersion;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// Forward Accept-Language so the backend locale middleware can resolve translations
|
|
105
|
-
const acceptLanguage = request.headers.get('accept-language');
|
|
106
|
-
if (acceptLanguage) {
|
|
107
|
-
headers['Accept-Language'] = acceptLanguage;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// Add auth token from httpOnly cookie
|
|
111
|
-
const cookieStore = await cookies();
|
|
112
|
-
const tokenCookie = cookieStore.get(TOKEN_COOKIE);
|
|
113
|
-
if (tokenCookie?.value) {
|
|
114
|
-
headers['Authorization'] = `Bearer ${tokenCookie.value}`;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// Forward request body for non-GET requests
|
|
118
|
-
let body: string | undefined;
|
|
119
|
-
if (method !== 'GET' && method !== 'HEAD') {
|
|
120
|
-
try {
|
|
121
|
-
body = await request.text();
|
|
122
|
-
} catch {
|
|
123
|
-
// No body
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// Proxy the request to backend
|
|
128
|
-
let backendResponse: Response;
|
|
129
|
-
const abortController = new AbortController();
|
|
130
|
-
const timeoutId = setTimeout(() => abortController.abort(), BACKEND_TIMEOUT_MS);
|
|
131
|
-
try {
|
|
132
|
-
backendResponse = await fetch(backendUrl.toString(), {
|
|
133
|
-
method,
|
|
134
|
-
headers,
|
|
135
|
-
body,
|
|
136
|
-
signal: abortController.signal,
|
|
137
|
-
});
|
|
138
|
-
} catch (error) {
|
|
139
|
-
const isTimeout = (error as Error)?.name === 'AbortError';
|
|
140
|
-
return NextResponse.json(
|
|
141
|
-
{ error: isTimeout ? 'Backend request timed out' : 'Backend service unavailable' },
|
|
142
|
-
{ status: isTimeout ? 504 : 502 }
|
|
143
|
-
);
|
|
144
|
-
} finally {
|
|
145
|
-
clearTimeout(timeoutId);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// Read response body
|
|
149
|
-
const responseText = await backendResponse.text();
|
|
150
|
-
|
|
151
|
-
// For auth endpoints: intercept token, set cookie, strip token from response
|
|
152
|
-
if (backendResponse.ok && method === 'POST' && isAuthEndpoint(pathSegments)) {
|
|
153
|
-
try {
|
|
154
|
-
const data = JSON.parse(responseText);
|
|
155
|
-
if (data.token) {
|
|
156
|
-
const token = data.token;
|
|
157
|
-
|
|
158
|
-
// Strip token from client response
|
|
159
|
-
const { token: _stripped, ...safeData } = data;
|
|
160
|
-
|
|
161
|
-
const response = NextResponse.json(safeData, {
|
|
162
|
-
status: backendResponse.status,
|
|
163
|
-
});
|
|
164
|
-
setAuthCookies(response, token);
|
|
165
|
-
return response;
|
|
166
|
-
}
|
|
167
|
-
} catch {
|
|
168
|
-
// Not JSON or no token field — pass through
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// Handle 401 responses: clear auth cookies
|
|
173
|
-
if (backendResponse.status === 401 && tokenCookie?.value) {
|
|
174
|
-
const response = new NextResponse(responseText, {
|
|
175
|
-
status: backendResponse.status,
|
|
176
|
-
headers: {
|
|
177
|
-
'Content-Type': backendResponse.headers.get('Content-Type') || 'application/json',
|
|
178
|
-
},
|
|
179
|
-
});
|
|
180
|
-
clearAuthCookies(response);
|
|
181
|
-
return response;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
// Sanitize 5xx responses so backend internals don't leak to the client
|
|
185
|
-
if (backendResponse.status >= 500) {
|
|
186
|
-
console.error(`[proxy] backend ${backendResponse.status} on ${pathSegments}:`, responseText);
|
|
187
|
-
return NextResponse.json(
|
|
188
|
-
{ error: 'Backend service error' },
|
|
189
|
-
{ status: backendResponse.status }
|
|
190
|
-
);
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
// Pass through response as-is
|
|
194
|
-
return new NextResponse(responseText, {
|
|
195
|
-
status: backendResponse.status,
|
|
196
|
-
headers: {
|
|
197
|
-
'Content-Type': backendResponse.headers.get('Content-Type') || 'application/json',
|
|
198
|
-
},
|
|
199
|
-
});
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
export async function GET(
|
|
203
|
-
request: NextRequest,
|
|
204
|
-
{ params }: { params: Promise<{ path: string[] }> }
|
|
205
|
-
) {
|
|
206
|
-
return proxyRequest(request, await params);
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
export async function POST(
|
|
210
|
-
request: NextRequest,
|
|
211
|
-
{ params }: { params: Promise<{ path: string[] }> }
|
|
212
|
-
) {
|
|
213
|
-
return proxyRequest(request, await params);
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
export async function PUT(
|
|
217
|
-
request: NextRequest,
|
|
218
|
-
{ params }: { params: Promise<{ path: string[] }> }
|
|
219
|
-
) {
|
|
220
|
-
return proxyRequest(request, await params);
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
export async function PATCH(
|
|
224
|
-
request: NextRequest,
|
|
225
|
-
{ params }: { params: Promise<{ path: string[] }> }
|
|
226
|
-
) {
|
|
227
|
-
return proxyRequest(request, await params);
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
export async function DELETE(
|
|
231
|
-
request: NextRequest,
|
|
232
|
-
{ params }: { params: Promise<{ path: string[] }> }
|
|
233
|
-
) {
|
|
234
|
-
return proxyRequest(request, await params);
|
|
235
|
-
}
|
|
1
|
+
// SECURITY: This BFF proxy intentionally has no application-level rate limiting.
|
|
2
|
+
// Rate limiting is the deployer's responsibility — configure it at the platform
|
|
3
|
+
// edge (Vercel Firewall, Cloudflare, nginx) or add a Redis-backed limiter
|
|
4
|
+
// (e.g. @upstash/ratelimit) here before going to production. Auth endpoints
|
|
5
|
+
// like customers/login and customers/register are the most important to cover.
|
|
6
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
7
|
+
import { cookies } from 'next/headers';
|
|
8
|
+
import { checkCsrf } from '@/lib/csrf';
|
|
9
|
+
|
|
10
|
+
const BACKEND_URL = (process.env.BRAINERCE_API_URL || 'https://api.brainerce.com').replace(
|
|
11
|
+
/\/$/,
|
|
12
|
+
''
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
const TOKEN_COOKIE = 'brainerce_customer_token';
|
|
16
|
+
const LOGGED_IN_COOKIE = 'brainerce_logged_in';
|
|
17
|
+
|
|
18
|
+
const COOKIE_MAX_AGE = 7 * 24 * 60 * 60; // 7 days
|
|
19
|
+
const BACKEND_TIMEOUT_MS = 15_000;
|
|
20
|
+
|
|
21
|
+
/** Auth endpoints whose responses contain tokens to intercept */
|
|
22
|
+
const AUTH_ENDPOINTS = ['customers/login', 'customers/register', 'customers/verify-email'];
|
|
23
|
+
|
|
24
|
+
function isAuthEndpoint(path: string): boolean {
|
|
25
|
+
return AUTH_ENDPOINTS.some((ep) => path.endsWith(ep));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isSafePathSegment(segment: string): boolean {
|
|
29
|
+
if (!segment) return false;
|
|
30
|
+
if (segment === '.' || segment === '..') return false;
|
|
31
|
+
if (segment.includes('/') || segment.includes('\\')) return false;
|
|
32
|
+
if (segment.includes('\0')) return false;
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isSecure(): boolean {
|
|
37
|
+
return process.env.NODE_ENV === 'production';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function setAuthCookies(response: NextResponse, token: string): void {
|
|
41
|
+
response.cookies.set(TOKEN_COOKIE, token, {
|
|
42
|
+
httpOnly: true,
|
|
43
|
+
secure: isSecure(),
|
|
44
|
+
sameSite: 'lax',
|
|
45
|
+
path: '/',
|
|
46
|
+
maxAge: COOKIE_MAX_AGE,
|
|
47
|
+
});
|
|
48
|
+
response.cookies.set(LOGGED_IN_COOKIE, '1', {
|
|
49
|
+
httpOnly: false,
|
|
50
|
+
secure: isSecure(),
|
|
51
|
+
sameSite: 'lax',
|
|
52
|
+
path: '/',
|
|
53
|
+
maxAge: COOKIE_MAX_AGE,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function clearAuthCookies(response: NextResponse): void {
|
|
58
|
+
response.cookies.delete(TOKEN_COOKIE);
|
|
59
|
+
response.cookies.delete(LOGGED_IN_COOKIE);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function proxyRequest(
|
|
63
|
+
request: NextRequest,
|
|
64
|
+
params: { path: string[] }
|
|
65
|
+
): Promise<NextResponse> {
|
|
66
|
+
const method = request.method;
|
|
67
|
+
|
|
68
|
+
// Reject path-traversal attempts before constructing the backend URL
|
|
69
|
+
if (!params.path.every(isSafePathSegment)) {
|
|
70
|
+
return NextResponse.json({ error: 'Invalid path' }, { status: 400 });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// CSRF protection for mutating requests
|
|
74
|
+
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
|
|
75
|
+
const csrfError = checkCsrf(request);
|
|
76
|
+
if (csrfError) return csrfError;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Build backend URL from path segments
|
|
80
|
+
const pathSegments = params.path.join('/');
|
|
81
|
+
const backendUrl = new URL(`${BACKEND_URL}/${pathSegments}`);
|
|
82
|
+
|
|
83
|
+
// Forward query parameters
|
|
84
|
+
request.nextUrl.searchParams.forEach((value, key) => {
|
|
85
|
+
backendUrl.searchParams.set(key, value);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Build headers for backend request
|
|
89
|
+
const headers: Record<string, string> = {
|
|
90
|
+
'Content-Type': 'application/json',
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// Send the proxy's own origin (not the client-supplied Origin header).
|
|
94
|
+
// The backend's BrowserOriginGuard only checks for presence of Origin/Referer,
|
|
95
|
+
// so forwarding a client-controlled value adds spoofing surface for nothing.
|
|
96
|
+
headers['Origin'] = request.nextUrl.origin;
|
|
97
|
+
|
|
98
|
+
// Forward SDK version header if present
|
|
99
|
+
const sdkVersion = request.headers.get('x-sdk-version');
|
|
100
|
+
if (sdkVersion) {
|
|
101
|
+
headers['X-SDK-Version'] = sdkVersion;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Forward Accept-Language so the backend locale middleware can resolve translations
|
|
105
|
+
const acceptLanguage = request.headers.get('accept-language');
|
|
106
|
+
if (acceptLanguage) {
|
|
107
|
+
headers['Accept-Language'] = acceptLanguage;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Add auth token from httpOnly cookie
|
|
111
|
+
const cookieStore = await cookies();
|
|
112
|
+
const tokenCookie = cookieStore.get(TOKEN_COOKIE);
|
|
113
|
+
if (tokenCookie?.value) {
|
|
114
|
+
headers['Authorization'] = `Bearer ${tokenCookie.value}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Forward request body for non-GET requests
|
|
118
|
+
let body: string | undefined;
|
|
119
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
120
|
+
try {
|
|
121
|
+
body = await request.text();
|
|
122
|
+
} catch {
|
|
123
|
+
// No body
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Proxy the request to backend
|
|
128
|
+
let backendResponse: Response;
|
|
129
|
+
const abortController = new AbortController();
|
|
130
|
+
const timeoutId = setTimeout(() => abortController.abort(), BACKEND_TIMEOUT_MS);
|
|
131
|
+
try {
|
|
132
|
+
backendResponse = await fetch(backendUrl.toString(), {
|
|
133
|
+
method,
|
|
134
|
+
headers,
|
|
135
|
+
body,
|
|
136
|
+
signal: abortController.signal,
|
|
137
|
+
});
|
|
138
|
+
} catch (error) {
|
|
139
|
+
const isTimeout = (error as Error)?.name === 'AbortError';
|
|
140
|
+
return NextResponse.json(
|
|
141
|
+
{ error: isTimeout ? 'Backend request timed out' : 'Backend service unavailable' },
|
|
142
|
+
{ status: isTimeout ? 504 : 502 }
|
|
143
|
+
);
|
|
144
|
+
} finally {
|
|
145
|
+
clearTimeout(timeoutId);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Read response body
|
|
149
|
+
const responseText = await backendResponse.text();
|
|
150
|
+
|
|
151
|
+
// For auth endpoints: intercept token, set cookie, strip token from response
|
|
152
|
+
if (backendResponse.ok && method === 'POST' && isAuthEndpoint(pathSegments)) {
|
|
153
|
+
try {
|
|
154
|
+
const data = JSON.parse(responseText);
|
|
155
|
+
if (data.token) {
|
|
156
|
+
const token = data.token;
|
|
157
|
+
|
|
158
|
+
// Strip token from client response
|
|
159
|
+
const { token: _stripped, ...safeData } = data;
|
|
160
|
+
|
|
161
|
+
const response = NextResponse.json(safeData, {
|
|
162
|
+
status: backendResponse.status,
|
|
163
|
+
});
|
|
164
|
+
setAuthCookies(response, token);
|
|
165
|
+
return response;
|
|
166
|
+
}
|
|
167
|
+
} catch {
|
|
168
|
+
// Not JSON or no token field — pass through
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Handle 401 responses: clear auth cookies
|
|
173
|
+
if (backendResponse.status === 401 && tokenCookie?.value) {
|
|
174
|
+
const response = new NextResponse(responseText, {
|
|
175
|
+
status: backendResponse.status,
|
|
176
|
+
headers: {
|
|
177
|
+
'Content-Type': backendResponse.headers.get('Content-Type') || 'application/json',
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
clearAuthCookies(response);
|
|
181
|
+
return response;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Sanitize 5xx responses so backend internals don't leak to the client
|
|
185
|
+
if (backendResponse.status >= 500) {
|
|
186
|
+
console.error(`[proxy] backend ${backendResponse.status} on ${pathSegments}:`, responseText);
|
|
187
|
+
return NextResponse.json(
|
|
188
|
+
{ error: 'Backend service error' },
|
|
189
|
+
{ status: backendResponse.status }
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Pass through response as-is
|
|
194
|
+
return new NextResponse(responseText, {
|
|
195
|
+
status: backendResponse.status,
|
|
196
|
+
headers: {
|
|
197
|
+
'Content-Type': backendResponse.headers.get('Content-Type') || 'application/json',
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export async function GET(
|
|
203
|
+
request: NextRequest,
|
|
204
|
+
{ params }: { params: Promise<{ path: string[] }> }
|
|
205
|
+
) {
|
|
206
|
+
return proxyRequest(request, await params);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export async function POST(
|
|
210
|
+
request: NextRequest,
|
|
211
|
+
{ params }: { params: Promise<{ path: string[] }> }
|
|
212
|
+
) {
|
|
213
|
+
return proxyRequest(request, await params);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export async function PUT(
|
|
217
|
+
request: NextRequest,
|
|
218
|
+
{ params }: { params: Promise<{ path: string[] }> }
|
|
219
|
+
) {
|
|
220
|
+
return proxyRequest(request, await params);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export async function PATCH(
|
|
224
|
+
request: NextRequest,
|
|
225
|
+
{ params }: { params: Promise<{ path: string[] }> }
|
|
226
|
+
) {
|
|
227
|
+
return proxyRequest(request, await params);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export async function DELETE(
|
|
231
|
+
request: NextRequest,
|
|
232
|
+
{ params }: { params: Promise<{ path: string[] }> }
|
|
233
|
+
) {
|
|
234
|
+
return proxyRequest(request, await params);
|
|
235
|
+
}
|
|
@@ -9,7 +9,6 @@ import type {
|
|
|
9
9
|
} from 'brainerce';
|
|
10
10
|
import { getClient } from '@/lib/brainerce';
|
|
11
11
|
import { useCart } from '@/providers/store-provider';
|
|
12
|
-
import { useStoreInfo } from '@/providers/store-provider';
|
|
13
12
|
import { CartItem } from '@/components/cart/cart-item';
|
|
14
13
|
import { CartUpgradeBanner } from '@/components/cart/cart-upgrade-banner';
|
|
15
14
|
import { CartBundleOfferCard } from '@/components/cart/cart-bundle-offer';
|
|
@@ -24,55 +23,30 @@ import { useTranslations } from '@/lib/translations';
|
|
|
24
23
|
|
|
25
24
|
export default function CartPage() {
|
|
26
25
|
const { cart, cartLoading, refreshCart, itemCount } = useCart();
|
|
27
|
-
const { storeInfo } = useStoreInfo();
|
|
28
26
|
const t = useTranslations('cart');
|
|
29
27
|
const tc = useTranslations('common');
|
|
30
28
|
const [cartRecs, setCartRecs] = useState<CartRecommendationsResponse | null>(null);
|
|
31
29
|
const [upgrades, setUpgrades] = useState<CartUpgradesResponse | null>(null);
|
|
32
30
|
const [bundles, setBundles] = useState<CartBundlesResponse | null>(null);
|
|
33
31
|
|
|
34
|
-
// Load
|
|
32
|
+
// Load recommendations, upgrades, and bundles in a single request
|
|
35
33
|
useEffect(() => {
|
|
36
34
|
if (!cart?.id || cart.items.length === 0) {
|
|
37
35
|
setCartRecs(null);
|
|
38
|
-
return;
|
|
39
|
-
}
|
|
40
|
-
const client = getClient();
|
|
41
|
-
client
|
|
42
|
-
.getCartRecommendations(cart.id, 4)
|
|
43
|
-
.then(setCartRecs)
|
|
44
|
-
.catch(() => {});
|
|
45
|
-
}, [cart?.id, cart?.items.length]);
|
|
46
|
-
|
|
47
|
-
// Load upgrade suggestions when cart changes
|
|
48
|
-
useEffect(() => {
|
|
49
|
-
if (
|
|
50
|
-
!cart?.id ||
|
|
51
|
-
cart.items.length === 0 ||
|
|
52
|
-
storeInfo?.upsell?.cartUpgradeBannerEnabled === false
|
|
53
|
-
) {
|
|
54
36
|
setUpgrades(null);
|
|
55
|
-
return;
|
|
56
|
-
}
|
|
57
|
-
const client = getClient();
|
|
58
|
-
client
|
|
59
|
-
.getCartUpgrades(cart.id)
|
|
60
|
-
.then(setUpgrades)
|
|
61
|
-
.catch(() => {});
|
|
62
|
-
}, [cart?.id, cart?.items.length, storeInfo?.upsell?.cartUpgradeBannerEnabled]);
|
|
63
|
-
|
|
64
|
-
// Load bundle offers when cart changes
|
|
65
|
-
useEffect(() => {
|
|
66
|
-
if (!cart?.id || cart.items.length === 0 || storeInfo?.upsell?.cartBundleEnabled === false) {
|
|
67
37
|
setBundles(null);
|
|
68
38
|
return;
|
|
69
39
|
}
|
|
70
40
|
const client = getClient();
|
|
71
41
|
client
|
|
72
|
-
.
|
|
73
|
-
.then(
|
|
42
|
+
.getCart(cart.id, { include: ['recommendations', 'upgrades', 'bundles'] })
|
|
43
|
+
.then((enriched) => {
|
|
44
|
+
setCartRecs(enriched.recommendations ?? null);
|
|
45
|
+
setUpgrades(enriched.upgrades ?? null);
|
|
46
|
+
setBundles(enriched.bundles ?? null);
|
|
47
|
+
})
|
|
74
48
|
.catch(() => {});
|
|
75
|
-
}, [cart?.id, cart?.items.length
|
|
49
|
+
}, [cart?.id, cart?.items.length]);
|
|
76
50
|
|
|
77
51
|
if (cartLoading) {
|
|
78
52
|
return (
|