From 86e01ca3c610dc34d3815e1d63186fc76c83d69c Mon Sep 17 00:00:00 2001 From: Gregor Zurowski Date: Thu, 30 Jul 2026 13:14:37 +0200 Subject: [PATCH 1/4] Add API client for the email unsubscribe endpoint --- services/emailSubscription.service.ts | 41 +++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 services/emailSubscription.service.ts diff --git a/services/emailSubscription.service.ts b/services/emailSubscription.service.ts new file mode 100644 index 000000000..0431678cd --- /dev/null +++ b/services/emailSubscription.service.ts @@ -0,0 +1,41 @@ +import { ApiClient } from './client'; +import { ApiError } from './types'; + +const DEFAULT_ERROR_MESSAGE = 'Unable to unsubscribe right now. Please try again.'; + +export class EmailSubscriptionService { + private static readonly BASE_PATH = '/api/email'; + + static async unsubscribe(code: string): Promise { + const query = new URLSearchParams({ code }); + + try { + await ApiClient.post(`${this.BASE_PATH}/unsubscribe?${query.toString()}`); + } catch (error) { + throw new Error(this.getErrorMessage(error)); + } + } + + private static getErrorMessage(error: unknown): string { + const responseBody = error instanceof ApiError ? error.errors : undefined; + + if (!responseBody || typeof responseBody !== 'object') { + return DEFAULT_ERROR_MESSAGE; + } + + const body = responseBody as { + detail?: unknown; + code?: unknown; + }; + + if (typeof body.detail === 'string') { + return body.detail; + } + + if (Array.isArray(body.code) && body.code.length > 0 && typeof body.code[0] === 'string') { + return body.code[0]; + } + + return DEFAULT_ERROR_MESSAGE; + } +} From 947124f18608144fd879a73d9e0d887b833f4dc8 Mon Sep 17 00:00:00 2001 From: Gregor Zurowski Date: Thu, 30 Jul 2026 13:15:02 +0200 Subject: [PATCH 2/4] Add new email unsubscribe page --- .../unsubscribe/EmailUnsubscribeForm.tsx | 137 ++++++++++++++++++ app/email/unsubscribe/page.tsx | 24 +++ 2 files changed, 161 insertions(+) create mode 100644 app/email/unsubscribe/EmailUnsubscribeForm.tsx create mode 100644 app/email/unsubscribe/page.tsx diff --git a/app/email/unsubscribe/EmailUnsubscribeForm.tsx b/app/email/unsubscribe/EmailUnsubscribeForm.tsx new file mode 100644 index 000000000..3e3ee0878 --- /dev/null +++ b/app/email/unsubscribe/EmailUnsubscribeForm.tsx @@ -0,0 +1,137 @@ +'use client'; + +import Link from 'next/link'; +import { CircleCheck, LoaderCircle, MailX, TriangleAlert } from 'lucide-react'; +import { useState } from 'react'; +import { Button } from '@/components/ui/Button'; +import { Logo } from '@/components/ui/Logo'; +import { EmailSubscriptionService } from '@/services/emailSubscription.service'; + +type SubmissionState = 'idle' | 'submitting' | 'success' | 'error'; + +interface EmailUnsubscribeFormProps { + code: string; +} + +export function EmailUnsubscribeForm({ code }: EmailUnsubscribeFormProps) { + const [submissionState, setSubmissionState] = useState('idle'); + const [errorMessage, setErrorMessage] = useState(''); + const hasCode = code.length > 0; + + const handleUnsubscribe = async () => { + if (!hasCode || submissionState === 'submitting') { + return; + } + + setSubmissionState('submitting'); + setErrorMessage(''); + + try { + await EmailSubscriptionService.unsubscribe(code); + setSubmissionState('success'); + } catch (error) { + setErrorMessage( + error instanceof Error + ? error.message + : 'Unable to unsubscribe right now. Please try again.' + ); + setSubmissionState('error'); + } + }; + + const isSuccess = submissionState === 'success'; + const isSubmitting = submissionState === 'submitting'; + + return ( +
+
+ ); +} diff --git a/app/email/unsubscribe/page.tsx b/app/email/unsubscribe/page.tsx new file mode 100644 index 000000000..eb45e9405 --- /dev/null +++ b/app/email/unsubscribe/page.tsx @@ -0,0 +1,24 @@ +import { Metadata } from 'next'; +import { EmailUnsubscribeForm } from './EmailUnsubscribeForm'; + +export const metadata: Metadata = { + title: 'Unsubscribe from emails', + description: 'Manage your ResearchHub email subscription.', + robots: { + index: false, + follow: false, + }, +}; + +interface EmailUnsubscribePageProps { + searchParams: Promise<{ + code?: string | string[]; + }>; +} + +export default async function EmailUnsubscribePage({ searchParams }: EmailUnsubscribePageProps) { + const { code } = await searchParams; + const unsubscribeCode = typeof code === 'string' ? code : ''; + + return ; +} From 54d0b5dcbb3a299f9e76a04d33934a73f01dc311 Mon Sep 17 00:00:00 2001 From: Gregor Zurowski Date: Thu, 30 Jul 2026 14:22:52 +0200 Subject: [PATCH 3/4] [Minor] Mark props readonly --- app/email/unsubscribe/EmailUnsubscribeForm.tsx | 12 ++++++------ app/email/unsubscribe/page.tsx | 4 +++- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/app/email/unsubscribe/EmailUnsubscribeForm.tsx b/app/email/unsubscribe/EmailUnsubscribeForm.tsx index 3e3ee0878..88e490087 100644 --- a/app/email/unsubscribe/EmailUnsubscribeForm.tsx +++ b/app/email/unsubscribe/EmailUnsubscribeForm.tsx @@ -13,7 +13,7 @@ interface EmailUnsubscribeFormProps { code: string; } -export function EmailUnsubscribeForm({ code }: EmailUnsubscribeFormProps) { +export function EmailUnsubscribeForm({ code }: Readonly) { const [submissionState, setSubmissionState] = useState('idle'); const [errorMessage, setErrorMessage] = useState(''); const hasCode = code.length > 0; @@ -76,11 +76,11 @@ export function EmailUnsubscribeForm({ code }: EmailUnsubscribeFormProps) { {isSuccess ? "You're unsubscribed" : 'Unsubscribe from ResearchHub emails?'} -

- {isSuccess - ? "You won't receive notification emails from ResearchHub. Essential account emails, such as password resets, may still arrive." - : "You'll stop receiving notification emails at this address. Essential account emails, such as password resets, will still arrive."} -

+ {isSuccess && ( +

+ You won't receive notification emails from ResearchHub. +

+ )} {!hasCode && (
; } -export default async function EmailUnsubscribePage({ searchParams }: EmailUnsubscribePageProps) { +export default async function EmailUnsubscribePage({ + searchParams, +}: Readonly) { const { code } = await searchParams; const unsubscribeCode = typeof code === 'string' ? code : ''; From b429d534cd162b6b699d11709bbfa431b6100d65 Mon Sep 17 00:00:00 2001 From: Gregor Zurowski Date: Thu, 30 Jul 2026 16:08:56 +0200 Subject: [PATCH 4/4] Remove icon --- app/email/unsubscribe/EmailUnsubscribeForm.tsx | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/app/email/unsubscribe/EmailUnsubscribeForm.tsx b/app/email/unsubscribe/EmailUnsubscribeForm.tsx index 88e490087..22239d695 100644 --- a/app/email/unsubscribe/EmailUnsubscribeForm.tsx +++ b/app/email/unsubscribe/EmailUnsubscribeForm.tsx @@ -1,7 +1,7 @@ 'use client'; import Link from 'next/link'; -import { CircleCheck, LoaderCircle, MailX, TriangleAlert } from 'lucide-react'; +import { CircleCheck, LoaderCircle, TriangleAlert } from 'lucide-react'; import { useState } from 'react'; import { Button } from '@/components/ui/Button'; import { Logo } from '@/components/ui/Logo'; @@ -60,17 +60,11 @@ export function EmailUnsubscribeForm({ code }: Readonly
-
- {isSuccess ? ( + {isSuccess && ( +
+
+ )}

{isSuccess ? "You're unsubscribed" : 'Unsubscribe from ResearchHub emails?'}