Skip to content

Commit 8e05d02

Browse files
committed
feat: implement cookie consent management and analytics integration
1 parent 4a1486c commit 8e05d02

23 files changed

Lines changed: 2807 additions & 382 deletions

app/(landing)/me/profile-data.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export async function ProfileData() {
4242

4343
// Handle other errors
4444
const errorMessage =
45-
error instanceof Error ? error.message : 'Failed to load your profile';
45+
error instanceof Error ? error.message : JSON.stringify(error);
4646

4747
return (
4848
<section className='flex min-h-screen items-center justify-center'>

app/(landing)/me/settings/page.tsx

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { getMeServer, getUserSettingsServer } from '@/lib/api/auth-server';
2+
import { getServerUser } from '@/lib/auth/server-auth';
3+
import ProfileUpdateScreen from '@/components/profile/ProfileUpdateScreen';
4+
5+
export default async function SettingsPage() {
6+
const user = await getServerUser();
7+
8+
// Check if user is authenticated
9+
if (!user) {
10+
return (
11+
<section className='flex min-h-screen items-center justify-center'>
12+
<div className='text-red-500'>Please sign in to view your settings</div>
13+
</section>
14+
);
15+
}
16+
17+
try {
18+
// Fetch user profile data and settings - Use server-side versions that forward cookies from request headers
19+
const [userData, settingsData] = await Promise.all([
20+
getMeServer(),
21+
getUserSettingsServer().catch(() => ({})), // Fallback to empty object if settings don't exist
22+
]);
23+
24+
return (
25+
<ProfileUpdateScreen user={userData} initialSettings={settingsData} />
26+
);
27+
} catch (error) {
28+
// Handle authentication errors
29+
if (
30+
error &&
31+
typeof error === 'object' &&
32+
'status' in error &&
33+
(error.status === 401 || error.status === 403)
34+
) {
35+
return (
36+
<section className='flex min-h-screen items-center justify-center'>
37+
<div className='text-red-500'>
38+
Session expired. Please sign in again.
39+
</div>
40+
</section>
41+
);
42+
}
43+
44+
// Handle other errors
45+
const errorMessage =
46+
error instanceof Error ? error.message : JSON.stringify(error);
47+
48+
return (
49+
<section className='flex min-h-screen items-center justify-center'>
50+
<div className='text-red-500'>{errorMessage}</div>
51+
</section>
52+
);
53+
}
54+
}

app/api/proxy/[...path]/route.ts

Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
3+
const getBackendUrl = () => {
4+
let apiUrl =
5+
process.env.NEXT_PUBLIC_API_URL || 'https://staging-api.boundlessfi.xyz';
6+
7+
apiUrl = apiUrl.replace(/\/$/, '');
8+
apiUrl = apiUrl.replace(/\/api$/i, '');
9+
10+
return apiUrl;
11+
};
12+
13+
export async function GET(
14+
request: NextRequest,
15+
{ params }: { params: Promise<{ path: string[] }> }
16+
) {
17+
const resolvedParams = await params;
18+
return handleRequest(request, resolvedParams, 'GET');
19+
}
20+
21+
export async function POST(
22+
request: NextRequest,
23+
{ params }: { params: Promise<{ path: string[] }> }
24+
) {
25+
const resolvedParams = await params;
26+
return handleRequest(request, resolvedParams, 'POST');
27+
}
28+
29+
export async function PUT(
30+
request: NextRequest,
31+
{ params }: { params: Promise<{ path: string[] }> }
32+
) {
33+
const resolvedParams = await params;
34+
return handleRequest(request, resolvedParams, 'PUT');
35+
}
36+
37+
export async function PATCH(
38+
request: NextRequest,
39+
{ params }: { params: Promise<{ path: string[] }> }
40+
) {
41+
const resolvedParams = await params;
42+
return handleRequest(request, resolvedParams, 'PATCH');
43+
}
44+
45+
export async function DELETE(
46+
request: NextRequest,
47+
{ params }: { params: Promise<{ path: string[] }> }
48+
) {
49+
const resolvedParams = await params;
50+
return handleRequest(request, resolvedParams, 'DELETE');
51+
}
52+
53+
async function handleRequest(
54+
request: NextRequest,
55+
params: { path: string[] },
56+
method: string
57+
) {
58+
try {
59+
const backendUrl = getBackendUrl();
60+
const path = Array.isArray(params.path)
61+
? params.path.join('/')
62+
: params.path;
63+
64+
const cleanBackendUrl = backendUrl.replace(/\/api\/?$/i, '');
65+
const baseUrl = `${cleanBackendUrl}/api`;
66+
const cleanPath = path.replace(/^\/?api\/?/i, '');
67+
const url = `${baseUrl}/${cleanPath}`;
68+
69+
const searchParams = request.nextUrl.searchParams.toString();
70+
const fullUrl = searchParams ? `${url}?${searchParams}` : url;
71+
72+
const requestContentType = request.headers.get('content-type') || '';
73+
const isMultipartFormData = requestContentType.includes(
74+
'multipart/form-data'
75+
);
76+
const isApplicationJson = requestContentType.includes('application/json');
77+
78+
// Handle request body
79+
let body: BodyInit | undefined;
80+
if (method !== 'GET' && method !== 'DELETE' && method !== 'HEAD') {
81+
try {
82+
// Clone request to check if body exists
83+
const clonedRequest = request.clone();
84+
const hasBody = clonedRequest.body !== null;
85+
86+
if (hasBody) {
87+
if (isMultipartFormData) {
88+
body = await request.formData();
89+
} else if (isApplicationJson) {
90+
// Preserve JSON structure
91+
const text = await request.text();
92+
body = text.length > 0 ? text : undefined;
93+
} else {
94+
// Handle other content types (text/plain, application/xml, etc.)
95+
body = await request.text();
96+
}
97+
}
98+
} catch {}
99+
}
100+
101+
// Forward headers
102+
const headers: HeadersInit = {};
103+
request.headers.forEach((value, key) => {
104+
const lowerKey = key.toLowerCase();
105+
// Skip headers that cause issues or are set automatically
106+
if (
107+
![
108+
'host',
109+
'connection',
110+
'content-length',
111+
'transfer-encoding',
112+
'accept-encoding',
113+
].includes(lowerKey)
114+
) {
115+
headers[key] = value;
116+
}
117+
});
118+
119+
// For FormData, let fetch set Content-Type with boundary
120+
if (isMultipartFormData) {
121+
delete headers['content-type'];
122+
delete headers['Content-Type'];
123+
}
124+
125+
// Ensure cookies are forwarded
126+
const cookieHeader = request.headers.get('cookie');
127+
if (cookieHeader) {
128+
headers['cookie'] = cookieHeader;
129+
}
130+
131+
// Make request to backend with timeout
132+
const controller = new AbortController();
133+
const timeoutId = setTimeout(() => controller.abort(), 30000); // 30s timeout
134+
135+
try {
136+
const response = await fetch(fullUrl, {
137+
method,
138+
headers,
139+
body: body || undefined,
140+
signal: controller.signal,
141+
// Don't follow redirects automatically - let the client handle them
142+
redirect: 'manual',
143+
});
144+
145+
clearTimeout(timeoutId);
146+
147+
// Handle binary responses (images, PDFs, etc.)
148+
const responseContentType = response.headers.get('content-type') || '';
149+
const isBinaryResponse =
150+
responseContentType.includes('image/') ||
151+
responseContentType.includes('application/pdf') ||
152+
responseContentType.includes('application/octet-stream') ||
153+
responseContentType.includes('video/') ||
154+
responseContentType.includes('audio/');
155+
156+
let responseBody: string | ArrayBuffer;
157+
if (isBinaryResponse) {
158+
responseBody = await response.arrayBuffer();
159+
} else {
160+
responseBody = await response.text();
161+
}
162+
163+
// Build response headers
164+
const responseHeaders = new Headers();
165+
166+
response.headers.forEach((value, key) => {
167+
const lowerKey = key.toLowerCase();
168+
// Skip problematic headers
169+
if (
170+
[
171+
'access-control-allow-origin',
172+
'access-control-allow-credentials',
173+
'access-control-allow-methods',
174+
'access-control-allow-headers',
175+
'content-encoding',
176+
'transfer-encoding',
177+
'content-length',
178+
].includes(lowerKey)
179+
) {
180+
return;
181+
}
182+
183+
// Handle Set-Cookie headers properly
184+
if (lowerKey === 'set-cookie') {
185+
const setCookieValues = response.headers.getSetCookie();
186+
setCookieValues.forEach(cookie => {
187+
responseHeaders.append('Set-Cookie', cookie);
188+
});
189+
} else {
190+
responseHeaders.set(key, value);
191+
}
192+
});
193+
194+
// Ensure Content-Type is set
195+
responseHeaders.set(
196+
'Content-Type',
197+
responseContentType || 'application/json'
198+
);
199+
200+
// Set CORS headers
201+
responseHeaders.set('Access-Control-Allow-Origin', '*');
202+
responseHeaders.set(
203+
'Access-Control-Allow-Methods',
204+
'GET, POST, PUT, PATCH, DELETE, OPTIONS'
205+
);
206+
responseHeaders.set(
207+
'Access-Control-Allow-Headers',
208+
'Content-Type, Authorization, Cookie'
209+
);
210+
responseHeaders.set('Access-Control-Allow-Credentials', 'true');
211+
212+
// Handle redirects
213+
if (response.status >= 300 && response.status < 400) {
214+
const location = response.headers.get('location');
215+
if (location) {
216+
responseHeaders.set('Location', location);
217+
}
218+
return new NextResponse(null, {
219+
status: response.status,
220+
statusText: response.statusText,
221+
headers: responseHeaders,
222+
});
223+
}
224+
225+
// Return binary response
226+
if (isBinaryResponse && responseBody instanceof ArrayBuffer) {
227+
return new NextResponse(responseBody, {
228+
status: response.status,
229+
statusText: response.statusText,
230+
headers: responseHeaders,
231+
});
232+
}
233+
234+
// Return JSON response
235+
if (
236+
responseContentType.includes('application/json') &&
237+
typeof responseBody === 'string'
238+
) {
239+
let parsedBody: unknown;
240+
try {
241+
parsedBody = responseBody.length > 0 ? JSON.parse(responseBody) : {};
242+
} catch {
243+
// Return as text if JSON parsing fails
244+
return new NextResponse(responseBody, {
245+
status: response.status,
246+
statusText: response.statusText,
247+
headers: responseHeaders,
248+
});
249+
}
250+
return NextResponse.json(parsedBody, {
251+
status: response.status,
252+
statusText: response.statusText,
253+
headers: responseHeaders,
254+
});
255+
}
256+
257+
// Return text/other responses
258+
return new NextResponse(responseBody, {
259+
status: response.status,
260+
statusText: response.statusText,
261+
headers: responseHeaders,
262+
});
263+
} catch (fetchError) {
264+
clearTimeout(timeoutId);
265+
266+
// Handle timeout
267+
if (fetchError instanceof Error && fetchError.name === 'AbortError') {
268+
return NextResponse.json(
269+
{
270+
message:
271+
'Request timeout - backend did not respond within 30 seconds',
272+
error: 'TIMEOUT_ERROR',
273+
},
274+
{ status: 504 }
275+
);
276+
}
277+
278+
throw fetchError;
279+
}
280+
} catch (error) {
281+
// Provide more specific error messages
282+
let errorMessage = 'Proxy request failed';
283+
let statusCode = 500;
284+
285+
if (error instanceof TypeError && error.message.includes('fetch')) {
286+
errorMessage = 'Failed to connect to backend server';
287+
statusCode = 502; // Bad Gateway
288+
} else if (error instanceof Error) {
289+
errorMessage = error.message;
290+
}
291+
292+
return NextResponse.json(
293+
{
294+
message: errorMessage,
295+
error: 'PROXY_ERROR',
296+
},
297+
{ status: statusCode }
298+
);
299+
}
300+
}
301+
302+
// Handle OPTIONS for CORS preflight
303+
export async function OPTIONS() {
304+
return new NextResponse(null, {
305+
status: 204, // 204 No Content is more appropriate for OPTIONS
306+
headers: {
307+
'Access-Control-Allow-Origin': '*',
308+
'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS',
309+
'Access-Control-Allow-Headers': 'Content-Type, Authorization, Cookie',
310+
'Access-Control-Allow-Credentials': 'true',
311+
'Access-Control-Max-Age': '86400', // Cache preflight for 24 hours
312+
},
313+
});
314+
}

app/layout.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import { Inter } from 'next/font/google';
33
import './globals.css';
44
import { Toaster } from 'sonner';
55
import { Providers } from './providers';
6-
import { Analytics } from '@vercel/analytics/next';
6+
import { AnalyticsWrapper } from '@/components/AnalyticsWrapper';
7+
import CookieConsent from '@/components/CookieConsent';
78
import {
89
generateOrganizationStructuredData,
910
generateWebsiteStructuredData,
@@ -93,8 +94,9 @@ export default function RootLayout({
9394
<Providers>
9495
{children}
9596
<Toaster />
97+
<CookieConsent />
9698
</Providers>
97-
<Analytics />
99+
<AnalyticsWrapper />
98100
</body>
99101
</html>
100102
);

0 commit comments

Comments
 (0)