From 66bd1a54f91261783056f5f6ee2cef352356395f Mon Sep 17 00:00:00 2001 From: ryzen_xp Date: Sat, 7 Mar 2026 07:43:55 +0530 Subject: [PATCH 1/3] feat(newsletter): implement newsletter API integration with subscribe, confirm, unsubscribe, and preferences --- .../newsletter/confirm/error/page.tsx | 32 ++++++++++++ app/(landing)/newsletter/confirmed/page.tsx | 14 +++++ .../newsletter/unsubscribed/page.tsx | 14 +++++ app/api/newsletter/confirm/[token]/route.ts | 21 ++++++++ app/api/newsletter/preferences/route.ts | 14 +++++ app/api/newsletter/subscribe/route.ts | 4 +- .../newsletter/unsubscribe/[token]/route.ts | 20 +++++++ app/api/newsletter/unsubscribe/route.ts | 15 ++++++ components/overview/Newsletter.tsx | 52 +++++++++++++++++-- lib/api/waitlist.ts | 34 ++++++++++-- 10 files changed, 208 insertions(+), 12 deletions(-) create mode 100644 app/(landing)/newsletter/confirm/error/page.tsx create mode 100644 app/(landing)/newsletter/confirmed/page.tsx create mode 100644 app/(landing)/newsletter/unsubscribed/page.tsx create mode 100644 app/api/newsletter/confirm/[token]/route.ts create mode 100644 app/api/newsletter/preferences/route.ts create mode 100644 app/api/newsletter/unsubscribe/[token]/route.ts create mode 100644 app/api/newsletter/unsubscribe/route.ts diff --git a/app/(landing)/newsletter/confirm/error/page.tsx b/app/(landing)/newsletter/confirm/error/page.tsx new file mode 100644 index 000000000..84de7a0ed --- /dev/null +++ b/app/(landing)/newsletter/confirm/error/page.tsx @@ -0,0 +1,32 @@ +'use client'; +import { useSearchParams } from 'next/navigation'; +import Link from 'next/link'; +import { Suspense } from 'react'; + +const msgs: Record = { + expired: 'This confirmation link has expired.', + invalid: 'This confirmation link is invalid or already used.', +}; + +function Content() { + const p = useSearchParams(); + return ( +
+

Confirmation failed

+

+ {msgs[p.get('reason') ?? ''] ?? 'An unexpected error occurred.'} +

+ + Back to home + +
+ ); +} + +export default function Page() { + return ( + + + + ); +} diff --git a/app/(landing)/newsletter/confirmed/page.tsx b/app/(landing)/newsletter/confirmed/page.tsx new file mode 100644 index 000000000..67718529c --- /dev/null +++ b/app/(landing)/newsletter/confirmed/page.tsx @@ -0,0 +1,14 @@ +import Link from 'next/link'; +export default function NewsletterConfirmedPage() { + return ( +
+

You're subscribed! 🎉

+

+ Your subscription has been confirmed. Welcome aboard! +

+ + Back to home + +
+ ); +} diff --git a/app/(landing)/newsletter/unsubscribed/page.tsx b/app/(landing)/newsletter/unsubscribed/page.tsx new file mode 100644 index 000000000..0d758a3ca --- /dev/null +++ b/app/(landing)/newsletter/unsubscribed/page.tsx @@ -0,0 +1,14 @@ +import Link from 'next/link'; +export default function NewsletterUnsubscribedPage() { + return ( +
+

You've been unsubscribed

+

+ You won't receive any more emails from us. +

+ + Back to home + +
+ ); +} diff --git a/app/api/newsletter/confirm/[token]/route.ts b/app/api/newsletter/confirm/[token]/route.ts new file mode 100644 index 000000000..56bb11e7c --- /dev/null +++ b/app/api/newsletter/confirm/[token]/route.ts @@ -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( + res.headers.get('Location') ?? `${appUrl}/newsletter/confirmed` + ); + const reason = res.status === 400 ? 'expired' : 'invalid'; + return NextResponse.redirect( + `${appUrl}/newsletter/confirm/error?reason=${reason}` + ); +} diff --git a/app/api/newsletter/preferences/route.ts b/app/api/newsletter/preferences/route.ts new file mode 100644 index 000000000..fac2e6b08 --- /dev/null +++ b/app/api/newsletter/preferences/route.ts @@ -0,0 +1,14 @@ +import { NextRequest, NextResponse } from 'next/server'; +const backendUrl = process.env.NEXT_PUBLIC_API_URL; + +export async function PATCH(req: NextRequest) { + const body = await req.json(); + 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, + }); +} diff --git a/app/api/newsletter/subscribe/route.ts b/app/api/newsletter/subscribe/route.ts index 9b7b0fca7..99e4546a5 100644 --- a/app/api/newsletter/subscribe/route.ts +++ b/app/api/newsletter/subscribe/route.ts @@ -4,13 +4,11 @@ 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', diff --git a/app/api/newsletter/unsubscribe/[token]/route.ts b/app/api/newsletter/unsubscribe/[token]/route.ts new file mode 100644 index 000000000..e9ca8a971 --- /dev/null +++ b/app/api/newsletter/unsubscribe/[token]/route.ts @@ -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 ?? ''; + +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( + res.headers.get('Location') ?? `${appUrl}/newsletter/unsubscribed` + ); + return NextResponse.redirect( + `${appUrl}/newsletter/unsubscribe/error?reason=invalid` + ); +} diff --git a/app/api/newsletter/unsubscribe/route.ts b/app/api/newsletter/unsubscribe/route.ts new file mode 100644 index 000000000..64e31d8d9 --- /dev/null +++ b/app/api/newsletter/unsubscribe/route.ts @@ -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, + }); +} diff --git a/components/overview/Newsletter.tsx b/components/overview/Newsletter.tsx index eac5a25d6..61cc35def 100644 --- a/components/overview/Newsletter.tsx +++ b/components/overview/Newsletter.tsx @@ -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({ @@ -42,6 +46,7 @@ const Newsletter = ({ }) => { const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); + const [selectedTags, setSelectedTags] = useState([]); const form = useForm>({ resolver: zodResolver(formSchema), defaultValues: { @@ -82,15 +87,21 @@ const Newsletter = ({ const onSubmit = async (values: z.infer) => { 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); + } 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); } @@ -186,6 +197,37 @@ const Newsletter = ({ )} /> + +
+ {( + [ + 'bounties', + 'hackathons', + 'grants', + 'updates', + ] as NewsletterTag[] + ).map(tag => ( + + ))} +
+ { @@ -38,10 +53,21 @@ export const newsletterSubscribe = async (data: NewsletterSubscribeRequest) => { body: JSON.stringify(data), }); + const body = await res.json().catch(() => ({})); + if (!res.ok) { - const errorData = await res.json().catch(() => ({})); - throw new Error(errorData.message || 'Failed to subscribe to newsletter'); + const codeMap: Record = { + 409: 'ALREADY_SUBSCRIBED', + 429: 'RATE_LIMITED', + 400: 'INVALID_TAGS', + 404: 'NOT_FOUND', + }; + throw { + status: res.status, + code: codeMap[res.status] ?? 'UNKNOWN', + message: body.message ?? 'Error', + } as NewsletterApiError; } - return res.json(); + return body; }; From bb0c965ed3b717d30909560e8026a5ae634eabce Mon Sep 17 00:00:00 2001 From: ryzen_xp Date: Sat, 7 Mar 2026 22:55:19 +0530 Subject: [PATCH 2/3] Draft feat(newsletter): Imp some extra things --- .../newsletter/unsubscribe/error/page.tsx | 31 +++++++ app/api/newsletter/confirm/[token]/route.ts | 4 +- app/api/newsletter/preferences/route.ts | 1 + app/api/newsletter/subscribe/route.ts | 21 +---- .../newsletter/unsubscribe/[token]/route.ts | 4 +- components/overview/Newsletter.tsx | 2 + lib/api/waitlist.ts | 84 +++++++++++++++---- 7 files changed, 110 insertions(+), 37 deletions(-) create mode 100644 app/(landing)/newsletter/unsubscribe/error/page.tsx diff --git a/app/(landing)/newsletter/unsubscribe/error/page.tsx b/app/(landing)/newsletter/unsubscribe/error/page.tsx new file mode 100644 index 000000000..b957980a1 --- /dev/null +++ b/app/(landing)/newsletter/unsubscribe/error/page.tsx @@ -0,0 +1,31 @@ +'use client'; +import { useSearchParams } from 'next/navigation'; +import Link from 'next/link'; +import { Suspense } from 'react'; + +const msgs: Record = { + invalid: 'This unsubscribe link is invalid or has already been used.', +}; + +function Content() { + const p = useSearchParams(); + return ( +
+

Unsubscribe failed

+

+ {msgs[p.get('reason') ?? ''] ?? 'An unexpected error occurred.'} +

+ + Back to home + +
+ ); +} + +export default function Page() { + return ( + + + + ); +} diff --git a/app/api/newsletter/confirm/[token]/route.ts b/app/api/newsletter/confirm/[token]/route.ts index 56bb11e7c..f752170f5 100644 --- a/app/api/newsletter/confirm/[token]/route.ts +++ b/app/api/newsletter/confirm/[token]/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; + const backendUrl = process.env.NEXT_PUBLIC_API_URL; const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? ''; @@ -10,10 +11,11 @@ export async function GET( const res = await fetch(`${backendUrl}/api/newsletter/confirm/${token}`, { redirect: 'manual', }); - if (res.status === 302) + if (res.status === 302) { return NextResponse.redirect( res.headers.get('Location') ?? `${appUrl}/newsletter/confirmed` ); + } const reason = res.status === 400 ? 'expired' : 'invalid'; return NextResponse.redirect( `${appUrl}/newsletter/confirm/error?reason=${reason}` diff --git a/app/api/newsletter/preferences/route.ts b/app/api/newsletter/preferences/route.ts index fac2e6b08..9bdd61c03 100644 --- a/app/api/newsletter/preferences/route.ts +++ b/app/api/newsletter/preferences/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; + const backendUrl = process.env.NEXT_PUBLIC_API_URL; export async function PATCH(req: NextRequest) { diff --git a/app/api/newsletter/subscribe/route.ts b/app/api/newsletter/subscribe/route.ts index 99e4546a5..31f0f067f 100644 --- a/app/api/newsletter/subscribe/route.ts +++ b/app/api/newsletter/subscribe/route.ts @@ -12,7 +12,6 @@ export async function POST(request: NextRequest) { method: 'POST', headers: { 'Content-Type': 'application/json', - ...(request.headers.get('user-agent') && { 'User-Agent': request.headers.get('user-agent')!, }), @@ -20,25 +19,11 @@ export async function POST(request: NextRequest) { 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 } ); } diff --git a/app/api/newsletter/unsubscribe/[token]/route.ts b/app/api/newsletter/unsubscribe/[token]/route.ts index e9ca8a971..da044f044 100644 --- a/app/api/newsletter/unsubscribe/[token]/route.ts +++ b/app/api/newsletter/unsubscribe/[token]/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; + const backendUrl = process.env.NEXT_PUBLIC_API_URL; const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? ''; @@ -10,10 +11,11 @@ export async function GET( const res = await fetch(`${backendUrl}/api/newsletter/unsubscribe/${token}`, { redirect: 'manual', }); - if (res.status === 302) + if (res.status === 302) { return NextResponse.redirect( res.headers.get('Location') ?? `${appUrl}/newsletter/unsubscribed` ); + } return NextResponse.redirect( `${appUrl}/newsletter/unsubscribe/error?reason=invalid` ); diff --git a/components/overview/Newsletter.tsx b/components/overview/Newsletter.tsx index 61cc35def..f2360d1b6 100644 --- a/components/overview/Newsletter.tsx +++ b/components/overview/Newsletter.tsx @@ -94,6 +94,8 @@ const Newsletter = ({ source: 'website', tags: selectedTags, }); + onOpenChange(false); + window.location.href = '/newsletter/confirmed'; } catch (err) { const e = err as NewsletterApiError; if (e.code === 'ALREADY_SUBSCRIBED') diff --git a/lib/api/waitlist.ts b/lib/api/waitlist.ts index 075bf5f0e..8a34d0571 100644 --- a/lib/api/waitlist.ts +++ b/lib/api/waitlist.ts @@ -9,6 +9,13 @@ type AddToWaitlistRequest = { export type NewsletterTag = 'bounties' | 'hackathons' | 'grants' | 'updates'; +export const NEWSLETTER_TAGS: NewsletterTag[] = [ + 'bounties', + 'hackathons', + 'grants', + 'updates', +]; + type NewsletterSubscribeRequest = { email: string; name?: string; @@ -16,6 +23,15 @@ type NewsletterSubscribeRequest = { tags?: NewsletterTag[]; }; +type NewsletterUnsubscribeRequest = { + email: string; +}; + +type NewsletterPreferencesRequest = { + email: string; + tags: NewsletterTag[]; +}; + export type NewsletterApiError = { status: number; code: @@ -27,12 +43,25 @@ export type NewsletterApiError = { message: string; }; +const codeMap: Record = { + 400: 'INVALID_TAGS', + 404: 'NOT_FOUND', + 409: 'ALREADY_SUBSCRIBED', + 429: 'RATE_LIMITED', +}; + +function throwApiError(status: number, body: { message?: string }): never { + throw { + status, + code: codeMap[status] ?? 'UNKNOWN', + message: body.message ?? 'An unexpected error occurred.', + } as NewsletterApiError; +} + export const addToWaitlist = async (data: AddToWaitlistRequest) => { const res = await fetch('/api/waitlist/subscribe', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }); @@ -47,27 +76,48 @@ export const addToWaitlist = async (data: AddToWaitlistRequest) => { export const newsletterSubscribe = async (data: NewsletterSubscribeRequest) => { const res = await fetch('/api/newsletter/subscribe', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }); const body = await res.json().catch(() => ({})); + if (!res.ok) throwApiError(res.status, body); + return body as { message: string; id: string }; +}; - if (!res.ok) { - const codeMap: Record = { - 409: 'ALREADY_SUBSCRIBED', - 429: 'RATE_LIMITED', - 400: 'INVALID_TAGS', - 404: 'NOT_FOUND', - }; +export const newsletterUnsubscribe = async ( + data: NewsletterUnsubscribeRequest +) => { + const res = await fetch('/api/newsletter/unsubscribe', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + + const body = await res.json().catch(() => ({})); + if (!res.ok) throwApiError(res.status, body); + return body as { message: string }; +}; + +export const newsletterUpdatePreferences = async ( + data: NewsletterPreferencesRequest +) => { + const invalid = data.tags.filter(t => !NEWSLETTER_TAGS.includes(t)); + if (invalid.length > 0) { throw { - status: res.status, - code: codeMap[res.status] ?? 'UNKNOWN', - message: body.message ?? 'Error', + status: 400, + code: 'INVALID_TAGS', + message: `Invalid tags: ${invalid.join(', ')}. Allowed: ${NEWSLETTER_TAGS.join(', ')}.`, } as NewsletterApiError; } - return body; + const res = await fetch('/api/newsletter/preferences', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + + const body = await res.json().catch(() => ({})); + if (!res.ok) throwApiError(res.status, body); + return body as { message: string }; }; From f6bae2b176288391ee51b8d1a14da68cf2e45df7 Mon Sep 17 00:00:00 2001 From: ryzen_xp Date: Sun, 8 Mar 2026 08:35:16 +0530 Subject: [PATCH 3/3] feat(newsletter): add API routes, client helpers, and UI pages --- app/api/newsletter/confirm/[token]/route.ts | 4 +--- app/api/newsletter/preferences/route.ts | 15 ++++++++++++++- app/api/newsletter/unsubscribe/[token]/route.ts | 4 +--- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/app/api/newsletter/confirm/[token]/route.ts b/app/api/newsletter/confirm/[token]/route.ts index f752170f5..774ba0589 100644 --- a/app/api/newsletter/confirm/[token]/route.ts +++ b/app/api/newsletter/confirm/[token]/route.ts @@ -12,9 +12,7 @@ export async function GET( redirect: 'manual', }); if (res.status === 302) { - return NextResponse.redirect( - res.headers.get('Location') ?? `${appUrl}/newsletter/confirmed` - ); + return NextResponse.redirect(`${appUrl}/newsletter/confirmed`); } const reason = res.status === 400 ? 'expired' : 'invalid'; return NextResponse.redirect( diff --git a/app/api/newsletter/preferences/route.ts b/app/api/newsletter/preferences/route.ts index 9bdd61c03..36acc11b4 100644 --- a/app/api/newsletter/preferences/route.ts +++ b/app/api/newsletter/preferences/route.ts @@ -1,9 +1,22 @@ import { NextRequest, NextResponse } from 'next/server'; -const backendUrl = process.env.NEXT_PUBLIC_API_URL; +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' }, diff --git a/app/api/newsletter/unsubscribe/[token]/route.ts b/app/api/newsletter/unsubscribe/[token]/route.ts index da044f044..dfd12ccdb 100644 --- a/app/api/newsletter/unsubscribe/[token]/route.ts +++ b/app/api/newsletter/unsubscribe/[token]/route.ts @@ -12,9 +12,7 @@ export async function GET( redirect: 'manual', }); if (res.status === 302) { - return NextResponse.redirect( - res.headers.get('Location') ?? `${appUrl}/newsletter/unsubscribed` - ); + return NextResponse.redirect(`${appUrl}/newsletter/unsubscribed`); } return NextResponse.redirect( `${appUrl}/newsletter/unsubscribe/error?reason=invalid`