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/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/(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..774ba0589 --- /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(`${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..36acc11b4 --- /dev/null +++ b/app/api/newsletter/preferences/route.ts @@ -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, + }); +} diff --git a/app/api/newsletter/subscribe/route.ts b/app/api/newsletter/subscribe/route.ts index 9b7b0fca7..31f0f067f 100644 --- a/app/api/newsletter/subscribe/route.ts +++ b/app/api/newsletter/subscribe/route.ts @@ -4,17 +4,14 @@ 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')!, }), @@ -22,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 new file mode 100644 index 000000000..dfd12ccdb --- /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(`${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..f2360d1b6 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,23 @@ 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); + 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); } @@ -186,6 +199,37 @@ const Newsletter = ({ )} /> + +
+ {( + [ + 'bounties', + 'hackathons', + 'grants', + 'updates', + ] as NewsletterTag[] + ).map(tag => ( + + ))} +
+ = { + 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), }); @@ -32,16 +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), }); - if (!res.ok) { - const errorData = await res.json().catch(() => ({})); - throw new Error(errorData.message || 'Failed to subscribe to newsletter'); + const body = await res.json().catch(() => ({})); + if (!res.ok) throwApiError(res.status, body); + return body as { message: string; id: string }; +}; + +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: 400, + code: 'INVALID_TAGS', + message: `Invalid tags: ${invalid.join(', ')}. Allowed: ${NEWSLETTER_TAGS.join(', ')}.`, + } as NewsletterApiError; } - return res.json(); + 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 }; };