Skip to content

Commit 098b3c3

Browse files
committed
Merge branch 'main' of github.com:boundlessfi/boundless
2 parents e16dfee + 17d37c7 commit 098b3c3

13 files changed

Lines changed: 444 additions & 40 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
'use client';
2+
import { useSearchParams } from 'next/navigation';
3+
import Link from 'next/link';
4+
import { Suspense } from 'react';
5+
6+
const msgs: Record<string, string> = {
7+
expired: 'This confirmation link has expired.',
8+
invalid: 'This confirmation link is invalid or already used.',
9+
};
10+
11+
function Content() {
12+
const p = useSearchParams();
13+
return (
14+
<main className='flex min-h-screen flex-col items-center justify-center gap-4 text-white'>
15+
<h1 className='text-3xl font-semibold'>Confirmation failed</h1>
16+
<p className='text-[#D9D9D9]'>
17+
{msgs[p.get('reason') ?? ''] ?? 'An unexpected error occurred.'}
18+
</p>
19+
<Link href='/' className='text-[#A7F950] underline'>
20+
Back to home
21+
</Link>
22+
</main>
23+
);
24+
}
25+
26+
export default function Page() {
27+
return (
28+
<Suspense>
29+
<Content />
30+
</Suspense>
31+
);
32+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import Link from 'next/link';
2+
export default function NewsletterConfirmedPage() {
3+
return (
4+
<main className='flex min-h-screen flex-col items-center justify-center gap-4 text-white'>
5+
<h1 className='text-3xl font-semibold'>You&apos;re subscribed! 🎉</h1>
6+
<p className='text-[#D9D9D9]'>
7+
Your subscription has been confirmed. Welcome aboard!
8+
</p>
9+
<Link href='/' className='text-[#A7F950] underline'>
10+
Back to home
11+
</Link>
12+
</main>
13+
);
14+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
'use client';
2+
import { useSearchParams } from 'next/navigation';
3+
import Link from 'next/link';
4+
import { Suspense } from 'react';
5+
6+
const msgs: Record<string, string> = {
7+
invalid: 'This unsubscribe link is invalid or has already been used.',
8+
};
9+
10+
function Content() {
11+
const p = useSearchParams();
12+
return (
13+
<main className='flex min-h-screen flex-col items-center justify-center gap-4 text-white'>
14+
<h1 className='text-3xl font-semibold'>Unsubscribe failed</h1>
15+
<p className='text-[#D9D9D9]'>
16+
{msgs[p.get('reason') ?? ''] ?? 'An unexpected error occurred.'}
17+
</p>
18+
<Link href='/' className='text-[#A7F950] underline'>
19+
Back to home
20+
</Link>
21+
</main>
22+
);
23+
}
24+
25+
export default function Page() {
26+
return (
27+
<Suspense>
28+
<Content />
29+
</Suspense>
30+
);
31+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import Link from 'next/link';
2+
export default function NewsletterUnsubscribedPage() {
3+
return (
4+
<main className='flex min-h-screen flex-col items-center justify-center gap-4 text-white'>
5+
<h1 className='text-3xl font-semibold'>You&apos;ve been unsubscribed</h1>
6+
<p className='text-[#D9D9D9]'>
7+
You won&apos;t receive any more emails from us.
8+
</p>
9+
<Link href='/' className='text-[#A7F950] underline'>
10+
Back to home
11+
</Link>
12+
</main>
13+
);
14+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
3+
const backendUrl = process.env.NEXT_PUBLIC_API_URL;
4+
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? '';
5+
6+
export async function GET(
7+
_req: NextRequest,
8+
{ params }: { params: Promise<{ token: string }> }
9+
) {
10+
const { token } = await params;
11+
const res = await fetch(`${backendUrl}/api/newsletter/confirm/${token}`, {
12+
redirect: 'manual',
13+
});
14+
if (res.status === 302) {
15+
return NextResponse.redirect(`${appUrl}/newsletter/confirmed`);
16+
}
17+
const reason = res.status === 400 ? 'expired' : 'invalid';
18+
return NextResponse.redirect(
19+
`${appUrl}/newsletter/confirm/error?reason=${reason}`
20+
);
21+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
3+
function normalizeBackendUrl(raw: string | undefined): string | undefined {
4+
if (!raw) return undefined;
5+
return raw.replace(/\/$/, '').replace(/\/api$/i, '');
6+
}
7+
8+
const backendUrl = normalizeBackendUrl(process.env.NEXT_PUBLIC_API_URL);
9+
10+
export async function PATCH(req: NextRequest) {
11+
const body = await req.json();
12+
13+
if (!backendUrl) {
14+
return NextResponse.json(
15+
{ message: 'Server configuration error: NEXT_PUBLIC_API_URL is not set' },
16+
{ status: 500 }
17+
);
18+
}
19+
20+
const res = await fetch(`${backendUrl}/api/newsletter/preferences`, {
21+
method: 'PATCH',
22+
headers: { 'Content-Type': 'application/json' },
23+
body: JSON.stringify(body),
24+
});
25+
return NextResponse.json(await res.json().catch(() => ({})), {
26+
status: res.status,
27+
});
28+
}

app/api/newsletter/subscribe/route.ts

Lines changed: 4 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,43 +4,26 @@ export async function POST(request: NextRequest) {
44
try {
55
const body = await request.json();
66

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

13-
const response = await fetch(`${backendUrl}/api/waitlist/subscribe`, {
11+
const response = await fetch(`${backendUrl}/api/newsletter/subscribe`, {
1412
method: 'POST',
1513
headers: {
1614
'Content-Type': 'application/json',
17-
1815
...(request.headers.get('user-agent') && {
1916
'User-Agent': request.headers.get('user-agent')!,
2017
}),
2118
},
2219
body: JSON.stringify(body),
2320
});
2421

25-
if (!response.ok) {
26-
const errorData = await response.json().catch(() => ({}));
27-
return NextResponse.json(
28-
{
29-
message: errorData.message || 'Failed to subscribe to waitlist',
30-
status: response.status,
31-
},
32-
{ status: response.status }
33-
);
34-
}
35-
36-
const data = await response.json();
37-
return NextResponse.json(data, { status: 200 });
22+
const data = await response.json().catch(() => ({}));
23+
return NextResponse.json(data, { status: response.status });
3824
} catch {
3925
return NextResponse.json(
40-
{
41-
message: 'Internal server error',
42-
status: 500,
43-
},
26+
{ message: 'Internal server error', status: 500 },
4427
{ status: 500 }
4528
);
4629
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
3+
const backendUrl = process.env.NEXT_PUBLIC_API_URL;
4+
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? '';
5+
6+
export async function GET(
7+
_req: NextRequest,
8+
{ params }: { params: Promise<{ token: string }> }
9+
) {
10+
const { token } = await params;
11+
const res = await fetch(`${backendUrl}/api/newsletter/unsubscribe/${token}`, {
12+
redirect: 'manual',
13+
});
14+
if (res.status === 302) {
15+
return NextResponse.redirect(`${appUrl}/newsletter/unsubscribed`);
16+
}
17+
return NextResponse.redirect(
18+
`${appUrl}/newsletter/unsubscribe/error?reason=invalid`
19+
);
20+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
3+
const backendUrl = process.env.NEXT_PUBLIC_API_URL;
4+
5+
export async function POST(req: NextRequest) {
6+
const body = await req.json();
7+
const res = await fetch(`${backendUrl}/api/newsletter/unsubscribe`, {
8+
method: 'POST',
9+
headers: { 'Content-Type': 'application/json' },
10+
body: JSON.stringify(body),
11+
});
12+
return NextResponse.json(await res.json().catch(() => ({})), {
13+
status: res.status,
14+
});
15+
}

components/overview/Newsletter.tsx

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,11 @@ import { BoundlessButton } from '../buttons';
2525
import { Input } from '../ui/input';
2626
import gsap from 'gsap';
2727
import { useGSAP } from '@gsap/react';
28-
import { newsletterSubscribe } from '@/lib/api/waitlist';
28+
import {
29+
newsletterSubscribe,
30+
type NewsletterApiError,
31+
type NewsletterTag,
32+
} from '@/lib/api/waitlist';
2933
import { Button } from '../ui/button';
3034

3135
const formSchema = z.object({
@@ -42,6 +46,7 @@ const Newsletter = ({
4246
}) => {
4347
const [isSubmitting, setIsSubmitting] = useState(false);
4448
const [error, setError] = useState<string | null>(null);
49+
const [selectedTags, setSelectedTags] = useState<NewsletterTag[]>([]);
4550
const form = useForm<z.infer<typeof formSchema>>({
4651
resolver: zodResolver(formSchema),
4752
defaultValues: {
@@ -82,15 +87,23 @@ const Newsletter = ({
8287
const onSubmit = async (values: z.infer<typeof formSchema>) => {
8388
setError(null);
8489
setIsSubmitting(true);
85-
8690
try {
8791
await newsletterSubscribe({
8892
email: values.email,
8993
name: values.name,
94+
source: 'website',
95+
tags: selectedTags,
9096
});
91-
} catch {
92-
setError('Failed to submit form. Please try again.');
93-
setIsSubmitting(false);
97+
onOpenChange(false);
98+
window.location.href = '/newsletter/confirmed';
99+
} catch (err) {
100+
const e = err as NewsletterApiError;
101+
if (e.code === 'ALREADY_SUBSCRIBED')
102+
setError('This email is already subscribed.');
103+
else if (e.code === 'RATE_LIMITED')
104+
setError('Too many attempts. Please try again in a minute.');
105+
else if (e.code === 'INVALID_TAGS') setError('Invalid topic selection.');
106+
else setError('Failed to submit form. Please try again.');
94107
} finally {
95108
setIsSubmitting(false);
96109
}
@@ -186,6 +199,37 @@ const Newsletter = ({
186199
</FormItem>
187200
)}
188201
/>
202+
203+
<div className='mt-4 flex flex-wrap gap-3'>
204+
{(
205+
[
206+
'bounties',
207+
'hackathons',
208+
'grants',
209+
'updates',
210+
] as NewsletterTag[]
211+
).map(tag => (
212+
<label
213+
key={tag}
214+
className='flex cursor-pointer items-center gap-1.5 text-sm text-[#D9D9D9]'
215+
>
216+
<input
217+
type='checkbox'
218+
className='accent-[#A7F950]'
219+
checked={selectedTags.includes(tag)}
220+
onChange={() =>
221+
setSelectedTags(p =>
222+
p.includes(tag)
223+
? p.filter(t => t !== tag)
224+
: [...p, tag]
225+
)
226+
}
227+
/>
228+
{tag.charAt(0).toUpperCase() + tag.slice(1)}
229+
</label>
230+
))}
231+
</div>
232+
189233
<BoundlessButton
190234
variant={form.formState.isValid ? 'default' : 'outline'}
191235
type='submit'

0 commit comments

Comments
 (0)