Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions app/(landing)/newsletter/confirm/error/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
'use client';
import { useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { Suspense } from 'react';

const msgs: Record<string, string> = {
expired: 'This confirmation link has expired.',
invalid: 'This confirmation link is invalid or already used.',
};

function Content() {
const p = useSearchParams();
return (
<main className='flex min-h-screen flex-col items-center justify-center gap-4 text-white'>
<h1 className='text-3xl font-semibold'>Confirmation failed</h1>
<p className='text-[#D9D9D9]'>
{msgs[p.get('reason') ?? ''] ?? 'An unexpected error occurred.'}
</p>
<Link href='/' className='text-[#A7F950] underline'>
Back to home
</Link>
</main>
);
}

export default function Page() {
return (
<Suspense>
<Content />
</Suspense>
);
}
14 changes: 14 additions & 0 deletions app/(landing)/newsletter/confirmed/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import Link from 'next/link';
export default function NewsletterConfirmedPage() {
return (
<main className='flex min-h-screen flex-col items-center justify-center gap-4 text-white'>
<h1 className='text-3xl font-semibold'>You&apos;re subscribed! 🎉</h1>
<p className='text-[#D9D9D9]'>
Your subscription has been confirmed. Welcome aboard!
</p>
<Link href='/' className='text-[#A7F950] underline'>
Back to home
</Link>
</main>
);
}
31 changes: 31 additions & 0 deletions app/(landing)/newsletter/unsubscribe/error/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
'use client';
import { useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { Suspense } from 'react';

const msgs: Record<string, string> = {
invalid: 'This unsubscribe link is invalid or has already been used.',
};

function Content() {
const p = useSearchParams();
return (
<main className='flex min-h-screen flex-col items-center justify-center gap-4 text-white'>
<h1 className='text-3xl font-semibold'>Unsubscribe failed</h1>
<p className='text-[#D9D9D9]'>
{msgs[p.get('reason') ?? ''] ?? 'An unexpected error occurred.'}
</p>
<Link href='/' className='text-[#A7F950] underline'>
Back to home
</Link>
</main>
);
}

export default function Page() {
return (
<Suspense>
<Content />
</Suspense>
);
}
14 changes: 14 additions & 0 deletions app/(landing)/newsletter/unsubscribed/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import Link from 'next/link';
export default function NewsletterUnsubscribedPage() {
return (
<main className='flex min-h-screen flex-col items-center justify-center gap-4 text-white'>
<h1 className='text-3xl font-semibold'>You&apos;ve been unsubscribed</h1>
<p className='text-[#D9D9D9]'>
You won&apos;t receive any more emails from us.
</p>
<Link href='/' className='text-[#A7F950] underline'>
Back to home
</Link>
</main>
);
}
21 changes: 21 additions & 0 deletions app/api/newsletter/confirm/[token]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { NextRequest, NextResponse } from 'next/server';

const backendUrl = process.env.NEXT_PUBLIC_API_URL;
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? '';

export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ token: string }> }
) {
const { token } = await params;
const res = await fetch(`${backendUrl}/api/newsletter/confirm/${token}`, {
redirect: 'manual',
});
if (res.status === 302) {
return NextResponse.redirect(`${appUrl}/newsletter/confirmed`);
}
const reason = res.status === 400 ? 'expired' : 'invalid';
return NextResponse.redirect(
`${appUrl}/newsletter/confirm/error?reason=${reason}`
);
}
28 changes: 28 additions & 0 deletions app/api/newsletter/preferences/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from 'next/server';

function normalizeBackendUrl(raw: string | undefined): string | undefined {
if (!raw) return undefined;
return raw.replace(/\/$/, '').replace(/\/api$/i, '');
}

const backendUrl = normalizeBackendUrl(process.env.NEXT_PUBLIC_API_URL);

export async function PATCH(req: NextRequest) {
const body = await req.json();

if (!backendUrl) {
return NextResponse.json(
{ message: 'Server configuration error: NEXT_PUBLIC_API_URL is not set' },
{ status: 500 }
);
}

const res = await fetch(`${backendUrl}/api/newsletter/preferences`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return NextResponse.json(await res.json().catch(() => ({})), {
status: res.status,
});
Comment on lines +10 to +27

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add error handling for JSON parsing and network failures.

Two failure scenarios are not gracefully handled:

  1. Line 11: req.json() throws if the request body is malformed JSON, resulting in an unhandled exception.
  2. Lines 20-24: fetch() throws on network errors (e.g., backend unreachable), also resulting in an unhandled exception.

Both cases produce opaque 500 errors without meaningful messages.

🛠️ Proposed fix with error handling
 export async function PATCH(req: NextRequest) {
-  const body = await req.json();
+  let body;
+  try {
+    body = await req.json();
+  } catch {
+    return NextResponse.json(
+      { message: 'Invalid JSON body' },
+      { status: 400 }
+    );
+  }
 
   if (!backendUrl) {
     return NextResponse.json(
       { message: 'Server configuration error: NEXT_PUBLIC_API_URL is not set' },
       { status: 500 }
     );
   }
 
-  const res = await fetch(`${backendUrl}/api/newsletter/preferences`, {
-    method: 'PATCH',
-    headers: { 'Content-Type': 'application/json' },
-    body: JSON.stringify(body),
-  });
-  return NextResponse.json(await res.json().catch(() => ({})), {
-    status: res.status,
-  });
+  try {
+    const res = await fetch(`${backendUrl}/api/newsletter/preferences`, {
+      method: 'PATCH',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify(body),
+    });
+    return NextResponse.json(await res.json().catch(() => ({})), {
+      status: res.status,
+    });
+  } catch {
+    return NextResponse.json(
+      { message: 'Failed to connect to backend service' },
+      { status: 502 }
+    );
+  }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/api/newsletter/preferences/route.ts` around lines 10 - 27, The PATCH
handler currently calls req.json() and fetch(...) without catching errors; wrap
the request body parsing and the backend fetch in separate try/catch blocks
inside the exported PATCH function so malformed JSON returns a clear 400
response (use NextResponse.json with a descriptive message) and network/fetch
errors return a 502/503 with an explanatory message; after a successful fetch,
safely parse the backend response (handle res.json() errors) and preserve
res.status when returning via NextResponse.json; reference the PATCH function,
req.json(), fetch(`${backendUrl}/api/newsletter/preferences`), and the final
NextResponse.json to locate where to add these try/catch handlers and adjusted
status/messages.

}
25 changes: 4 additions & 21 deletions app/api/newsletter/subscribe/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,43 +4,26 @@ export async function POST(request: NextRequest) {
try {
const body = await request.json();

// Normalize API URL: remove trailing slash and /api if present
// The env var should be base URL without /api (e.g., https://api.boundlessfi.xyz)
let backendUrl =
process.env.NEXT_PUBLIC_API_URL || 'https://staging-api.boundlessfi.xyz';
backendUrl = backendUrl.replace(/\/$/, '').replace(/\/api$/i, '');

const response = await fetch(`${backendUrl}/api/waitlist/subscribe`, {
const response = await fetch(`${backendUrl}/api/newsletter/subscribe`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',

...(request.headers.get('user-agent') && {
'User-Agent': request.headers.get('user-agent')!,
}),
},
body: JSON.stringify(body),
});

if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
return NextResponse.json(
{
message: errorData.message || 'Failed to subscribe to waitlist',
status: response.status,
},
{ status: response.status }
);
}

const data = await response.json();
return NextResponse.json(data, { status: 200 });
const data = await response.json().catch(() => ({}));
return NextResponse.json(data, { status: response.status });
} catch {
return NextResponse.json(
{
message: 'Internal server error',
status: 500,
},
{ message: 'Internal server error', status: 500 },
{ status: 500 }
);
}
Expand Down
20 changes: 20 additions & 0 deletions app/api/newsletter/unsubscribe/[token]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from 'next/server';

const backendUrl = process.env.NEXT_PUBLIC_API_URL;
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? '';
Comment on lines +3 to +4

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Official Next.js docs: in App Router route handlers, should NextResponse.redirect()be given an absolute URL (for examplenew URL('/path', request.url)) rather than a relative string path?

💡 Result:

Yes—per the official Next.js docs, in App Router Route Handlers you should pass NextResponse.redirect() an absolute URL, typically by constructing one from the incoming request, e.g.:

return NextResponse.redirect(new URL('/path', request.url))

The NextResponse API reference shows redirect() producing a redirect to a URL object and uses new URL('/new', request.url) in its example [1]. Next.js also documents that passing a relative (or invalid) URL to NextResponse.redirect() is not supported and you “must always pass absolute URL” (either by cloning request.nextUrl or using new URL(..., request.url)) [2].


🏁 Script executed:

cat -n app/api/newsletter/unsubscribe/[token]/route.ts

Repository: boundlessfi/boundless

Length of output: 821


Fail closed when env config is missing or the backend is down.

If NEXT_PUBLIC_API_URL is unset, fetch() will throw here, and the '' fallback for appUrl produces relative redirect targets. Next.js App Router requires absolute URLs for NextResponse.redirect()—relative string paths are not supported and will fail. Additionally, uncaught fetch errors will return 500 instead of redirecting to the error page. Guard backendUrl, use _req.nextUrl.origin as the base, and catch fetch failures.

Suggested fix
 const backendUrl = process.env.NEXT_PUBLIC_API_URL;
-const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? '';
+const appUrl = process.env.NEXT_PUBLIC_APP_URL;

 export async function GET(
   _req: NextRequest,
   { params }: { params: Promise<{ token: string }> }
 ) {
+  const origin = appUrl ?? _req.nextUrl.origin;
+  if (!backendUrl) {
+    return NextResponse.redirect(
+      new URL('/newsletter/unsubscribe/error', origin)
+    );
+  }
+
   const { token } = await params;
-  const res = await fetch(`${backendUrl}/api/newsletter/unsubscribe/${token}`, {
-    redirect: 'manual',
-  });
+  let res: Response;
+  try {
+    res = await fetch(`${backendUrl}/api/newsletter/unsubscribe/${token}`, {
+      redirect: 'manual',
+    });
+  } catch {
+    return NextResponse.redirect(
+      new URL('/newsletter/unsubscribe/error', origin)
+    );
+  }
+
   if (res.status === 302) {
-    return NextResponse.redirect(`${appUrl}/newsletter/unsubscribed`);
+    return NextResponse.redirect(new URL('/newsletter/unsubscribed', origin));
   }
   return NextResponse.redirect(
-    `${appUrl}/newsletter/unsubscribe/error?reason=invalid`
+    new URL('/newsletter/unsubscribe/error?reason=invalid', origin)
   );
 }

Per Next.js documentation, NextResponse.redirect() requires absolute URLs constructed via new URL('/path', request.url). Also applies to lines 11–18.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/api/newsletter/unsubscribe/`[token]/route.ts around lines 3 - 4, The code
currently reads NEXT_PUBLIC_API_URL into backendUrl and falls back appUrl to ''
which leads to relative redirects and uncaught fetch errors; update the
unsubscribe route to fail closed: validate that backendUrl (NEXT_PUBLIC_API_URL)
is present before using fetch and, if missing, construct absolute URLs using the
incoming request origin (_req.nextUrl.origin) as the base for new URL('/...',
_req.nextUrl.origin) when calling NextResponse.redirect; also wrap the fetch
call in try/catch to handle network/backend failures and on error redirect to
the error page using an absolute URL built from _req.nextUrl.origin (or return a
safe error response) so redirects never use relative strings and fetch failures
are handled.


export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ token: string }> }
) {
const { token } = await params;
const res = await fetch(`${backendUrl}/api/newsletter/unsubscribe/${token}`, {
redirect: 'manual',
});
if (res.status === 302) {
return NextResponse.redirect(`${appUrl}/newsletter/unsubscribed`);
}
return NextResponse.redirect(
`${appUrl}/newsletter/unsubscribe/error?reason=invalid`
);
}
15 changes: 15 additions & 0 deletions app/api/newsletter/unsubscribe/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { NextRequest, NextResponse } from 'next/server';

const backendUrl = process.env.NEXT_PUBLIC_API_URL;

export async function POST(req: NextRequest) {
const body = await req.json();
const res = await fetch(`${backendUrl}/api/newsletter/unsubscribe`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return NextResponse.json(await res.json().catch(() => ({})), {
status: res.status,
});
}
54 changes: 49 additions & 5 deletions components/overview/Newsletter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,11 @@ import { BoundlessButton } from '../buttons';
import { Input } from '../ui/input';
import gsap from 'gsap';
import { useGSAP } from '@gsap/react';
import { newsletterSubscribe } from '@/lib/api/waitlist';
import {
newsletterSubscribe,
type NewsletterApiError,
type NewsletterTag,
} from '@/lib/api/waitlist';
import { Button } from '../ui/button';

const formSchema = z.object({
Expand All @@ -42,6 +46,7 @@ const Newsletter = ({
}) => {
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedTags, setSelectedTags] = useState<NewsletterTag[]>([]);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
Expand Down Expand Up @@ -82,15 +87,23 @@ const Newsletter = ({
const onSubmit = async (values: z.infer<typeof formSchema>) => {
setError(null);
setIsSubmitting(true);

try {
await newsletterSubscribe({
email: values.email,
name: values.name,
source: 'website',
tags: selectedTags,
});
} catch {
setError('Failed to submit form. Please try again.');
setIsSubmitting(false);
onOpenChange(false);
window.location.href = '/newsletter/confirmed';
} catch (err) {
const e = err as NewsletterApiError;
if (e.code === 'ALREADY_SUBSCRIBED')
setError('This email is already subscribed.');
else if (e.code === 'RATE_LIMITED')
setError('Too many attempts. Please try again in a minute.');
else if (e.code === 'INVALID_TAGS') setError('Invalid topic selection.');
else setError('Failed to submit form. Please try again.');
} finally {
setIsSubmitting(false);
}
Comment thread
ryzen-xp marked this conversation as resolved.
Expand Down Expand Up @@ -186,6 +199,37 @@ const Newsletter = ({
</FormItem>
)}
/>

<div className='mt-4 flex flex-wrap gap-3'>
{(
[
'bounties',
'hackathons',
'grants',
'updates',
] as NewsletterTag[]
).map(tag => (
<label
key={tag}
className='flex cursor-pointer items-center gap-1.5 text-sm text-[#D9D9D9]'
>
<input
type='checkbox'
className='accent-[#A7F950]'
checked={selectedTags.includes(tag)}
onChange={() =>
setSelectedTags(p =>
p.includes(tag)
? p.filter(t => t !== tag)
: [...p, tag]
)
}
/>
{tag.charAt(0).toUpperCase() + tag.slice(1)}
</label>
))}
</div>

<BoundlessButton
variant={form.formState.isValid ? 'default' : 'outline'}
type='submit'
Expand Down
Loading
Loading