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
131 changes: 131 additions & 0 deletions app/email/unsubscribe/EmailUnsubscribeForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
'use client';

import Link from 'next/link';
import { CircleCheck, LoaderCircle, 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 }: Readonly<EmailUnsubscribeFormProps>) {
const [submissionState, setSubmissionState] = useState<SubmissionState>('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 (
<main className="relative flex min-h-screen items-center justify-center overflow-hidden bg-slate-50 px-4 py-12">
<div
className="pointer-events-none absolute inset-x-0 top-0 h-80 bg-gradient-to-b from-primary-50 to-transparent"
aria-hidden="true"
/>

<section className="relative w-full max-w-md rounded-2xl border border-gray-200 bg-white px-6 py-8 shadow-xl shadow-gray-200/50 sm:px-10 sm:py-10">
<Link
href="/"
referrerPolicy="no-referrer"
className="mx-auto mb-8 block w-fit rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-4"
aria-label="ResearchHub home"
>
<Logo size={32} />
</Link>

<div className="text-center" aria-live="polite">
{isSuccess && (
<div className="mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-full bg-green-50 text-green-600">
<CircleCheck className="h-7 w-7" aria-hidden="true" />
</div>
)}

<h1 className="text-2xl font-semibold tracking-tight text-gray-900">
{isSuccess ? "You're unsubscribed" : 'Unsubscribe from ResearchHub emails?'}
</h1>

{isSuccess && (
<p className="mt-3 text-sm leading-6 text-gray-600">
You won't receive notification emails from ResearchHub.
</p>
)}

{!hasCode && (
<div
className="mt-6 flex items-start gap-3 rounded-lg border border-amber-200 bg-amber-50 p-4 text-left text-sm text-amber-900"
role="alert"
>
<TriangleAlert className="mt-0.5 h-5 w-5 shrink-0" aria-hidden="true" />
<p>This unsubscribe link is incomplete. Please use the link from your email.</p>
</div>
)}

{submissionState === 'error' && (
<div
className="mt-6 flex items-start gap-3 rounded-lg border border-red-200 bg-red-50 p-4 text-left text-sm text-red-800"
role="alert"
>
<TriangleAlert className="mt-0.5 h-5 w-5 shrink-0" aria-hidden="true" />
<p>{errorMessage}</p>
</div>
)}

<div className="mt-8 flex flex-col gap-3">
{!isSuccess && (
<Button
type="button"
size="lg"
className="w-full"
disabled={!hasCode || isSubmitting}
onClick={handleUnsubscribe}
>
{isSubmitting ? (
<>
<LoaderCircle className="mr-2 h-5 w-5 animate-spin" aria-hidden="true" />
Unsubscribing…
</>
) : (
'Unsubscribe'
)}
</Button>
)}

<Link
href="/"
referrerPolicy="no-referrer"
className="inline-flex h-12 items-center justify-center rounded-lg px-4 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2"
>
{isSuccess ? 'Return to ResearchHub' : 'Keep me subscribed'}
</Link>
</div>
</div>
</section>
</main>
);
}
26 changes: 26 additions & 0 deletions app/email/unsubscribe/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
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,
}: Readonly<EmailUnsubscribePageProps>) {
const { code } = await searchParams;
const unsubscribeCode = typeof code === 'string' ? code : '';

return <EmailUnsubscribeForm code={unsubscribeCode} />;
}
41 changes: 41 additions & 0 deletions services/emailSubscription.service.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
const query = new URLSearchParams({ code });

try {
await ApiClient.post(`${this.BASE_PATH}/unsubscribe?${query.toString()}`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Add the trailing slash to the unsubscribe POST

When a user confirms unsubscribe, this calls /api/email/unsubscribe?code=..., but the backend endpoint is registered as /api/email/unsubscribe/. Because this is a POST through fetch, the missing slash is not a safe redirect path (Django may redirect or reject before the view runs, and redirects can turn the request into a GET), so valid unsubscribe links will show the generic error instead of opting the email out. Please include the slash before the query string.

Useful? React with 👍 / 👎.

} 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;
}
}