feat: add notification settings and newsletter management features

- Introduced a new `SettingsNotificationPage` component for managing newsletter subscriptions.
- Added `NewsletterFormCard` to handle subscription status with user feedback through toast notifications.
- Implemented `isSubscribedAction` to check user subscription status, enhancing user experience.
- Updated localization files to include new messages for newsletter management in English and Chinese.
- Enhanced sidebar with a new notification settings link for easy access to subscription preferences.
This commit is contained in:
javayhu 2025-03-16 12:55:26 +08:00
parent 6413fcd33c
commit 5607b57bd1
13 changed files with 354 additions and 7 deletions

View File

@ -355,6 +355,22 @@
},
"billing": {
"title": "Billing"
},
"notification": {
"title": "Notification",
"newsletter": {
"title": "Newsletter Subscription",
"description": "Manage your newsletter subscription preferences",
"checkboxLabel": "Subscribe to newsletter",
"checkboxDescription": "Receive updates, news, and special offers via email",
"hint": "You can change your subscription preferences at any time",
"emailRequired": "Email is required to subscribe to the newsletter",
"subscribeSuccess": "Successfully subscribed to the newsletter",
"subscribeFail": "Failed to subscribe to the newsletter",
"unsubscribeSuccess": "Successfully unsubscribed from the newsletter",
"unsubscribeFail": "Failed to unsubscribe from the newsletter",
"error": "An error occurred while updating your subscription"
}
}
}
}

View File

@ -349,6 +349,22 @@
},
"billing": {
"title": "账单"
},
"notification": {
"title": "通知",
"newsletter": {
"title": "订阅",
"description": "管理您的邮件列表订阅偏好",
"checkboxLabel": "订阅邮件列表",
"checkboxDescription": "通过邮件接收更新、新闻和特别优惠",
"hint": "您可以随时更改订阅偏好",
"emailRequired": "订阅邮件列表需要邮箱",
"subscribeSuccess": "成功订阅邮件列表",
"subscribeFail": "订阅邮件列表失败",
"unsubscribeSuccess": "成功取消订阅邮件列表",
"unsubscribeFail": "取消订阅邮件列表失败",
"error": "更新订阅时发生错误"
}
}
}
}

View File

@ -2,7 +2,8 @@
import {
subscribe as subscribeToNewsletter,
unsubscribe as unsubscribeFromNewsletter
unsubscribe as unsubscribeFromNewsletter,
isSubscribed as checkIsSubscribed
} from '@/newsletter';
import { createSafeActionClient } from 'next-safe-action';
import { z } from 'zod';
@ -65,4 +66,25 @@ export const unsubscribeAction = actionClient
error: 'An unexpected error occurred',
};
}
});
// Create a safe action to check if a user is subscribed to the newsletter
export const isSubscribedAction = actionClient
.schema(newsletterSchema)
.action(async ({ parsedInput: { email } }) => {
try {
const subscribed = await checkIsSubscribed(email);
return {
success: true,
subscribed,
};
} catch (error) {
console.error('Newsletter subscription check error:', error);
return {
success: false,
subscribed: false,
error: 'An unexpected error occurred',
};
}
});

View File

@ -0,0 +1,32 @@
import { DashboardHeader } from '@/components/dashboard/dashboard-header';
import { NewsletterFormCard } from '@/components/settings/notification/newsletter-form-card';
import { useTranslations } from 'next-intl';
export default function SettingsNotificationPage() {
const t = useTranslations();
const breadcrumbs = [
{
label: t('Dashboard.sidebar.settings.title'),
isCurrentPage: false,
},
{
label: t('Dashboard.sidebar.settings.items.notification.title'),
isCurrentPage: true,
},
];
return (
<>
<DashboardHeader breadcrumbs={breadcrumbs} />
<div className="p-8">
<div className="grid grid-cols-1 md:grid-cols-1 gap-6 max-w-6xl">
<div className="space-y-6">
<NewsletterFormCard />
</div>
</div>
</div>
</>
);
}

View File

@ -1,5 +1,6 @@
'use client';
import { FormError } from '@/components/shared/form-error';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import {
@ -18,7 +19,8 @@ import { useEffect, useState } from 'react';
export function UpdateAvatarCard() {
const t = useTranslations('Dashboard.sidebar.settings.items.account');
const [isUploading, setIsUploading] = useState(false);
const { data: session, error } = authClient.useSession();
const [error, setError] = useState('');
const { data: session } = authClient.useSession();
const [avatarUrl, setAvatarUrl] = useState('');
useEffect(() => {
@ -71,7 +73,7 @@ export function UpdateAvatarCard() {
{t('avatar.description')}
</CardDescription>
</CardHeader>
<CardContent>
<CardContent className="space-y-4">
<div className="flex flex-col items-center sm:flex-row gap-4">
{/* avatar */}
<Avatar className="h-16 w-16 border">
@ -91,6 +93,8 @@ export function UpdateAvatarCard() {
{isUploading ? t('avatar.uploading') : t('avatar.uploadAvatar')}
</Button>
</div>
<FormError message={error} />
</CardContent>
<CardFooter className="px-6 py-4 flex justify-between items-center bg-muted rounded-none">
<p className="text-sm text-muted-foreground">

View File

@ -119,7 +119,7 @@ export function UpdateNameCard() {
</CardHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<CardContent className="space-y-6">
<CardContent className="space-y-4">
<FormField
control={form.control}
name="name"

View File

@ -0,0 +1,205 @@
'use client';
import { FormError } from '@/components/shared/form-error';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle
} from '@/components/ui/card';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
} from '@/components/ui/form';
import { Checkbox } from '@/components/ui/checkbox';
import { authClient } from '@/lib/auth-client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import {
subscribeAction,
unsubscribeAction,
isSubscribedAction
} from '@/actions/newsletter';
/**
* Newsletter subscription form card
*
* Allows users to toggle their newsletter subscription status
*/
export function NewsletterFormCard() {
const t = useTranslations('Dashboard.sidebar.settings.items.notification');
const [isUpdating, setIsUpdating] = useState(false);
const [error, setError] = useState<string | undefined>('');
const [isSubscriptionChecked, setIsSubscriptionChecked] = useState(false);
const { data: session } = authClient.useSession();
const user = session?.user;
// Create a schema for newsletter subscription
const formSchema = z.object({
subscribed: z.boolean().default(false),
});
// Initialize the form
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
subscribed: false,
},
});
// Check subscription status on component mount
useEffect(() => {
const checkSubscriptionStatus = async () => {
if (user?.email) {
try {
setIsUpdating(true);
// Check if the user is already subscribed using server action
const statusResult = await isSubscribedAction({ email: user.email });
if (statusResult && statusResult.data?.success) {
const isCurrentlySubscribed = statusResult.data.subscribed;
setIsSubscriptionChecked(isCurrentlySubscribed);
form.setValue('subscribed', isCurrentlySubscribed);
} else {
// Handle error from server action
const errorMessage = statusResult?.data?.error;
if (errorMessage) {
console.error('Error checking subscription status:', errorMessage);
setError(errorMessage);
}
// Default to not subscribed if there's an error
setIsSubscriptionChecked(false);
form.setValue('subscribed', false);
}
} catch (err) {
console.error('Error checking subscription status:', err);
// Default to not subscribed if there's an error
setIsSubscriptionChecked(false);
form.setValue('subscribed', false);
} finally {
setIsUpdating(false);
}
}
};
checkSubscriptionStatus();
}, [user?.email, form]);
// Check if user exists after all hooks are initialized
if (!user) {
return null;
}
// Handle checkbox change
const handleSubscriptionChange = async (value: boolean) => {
if (!user.email) {
setError(t('newsletter.emailRequired'));
return;
}
setIsUpdating(true);
setError('');
try {
if (value) {
// Subscribe to newsletter using server action
const subscribeResult = await subscribeAction({ email: user.email });
if (subscribeResult && subscribeResult.data?.success) {
toast.success(t('newsletter.subscribeSuccess'));
setIsSubscriptionChecked(true);
form.setValue('subscribed', true);
} else {
const errorMessage = subscribeResult?.data?.error || t('newsletter.subscribeFail');
toast.error(errorMessage);
setError(errorMessage);
// Reset checkbox if subscription failed
form.setValue('subscribed', false);
}
} else {
// Unsubscribe from newsletter using server action
const unsubscribeResult = await unsubscribeAction({ email: user.email });
if (unsubscribeResult && unsubscribeResult.data?.success) {
toast.success(t('newsletter.unsubscribeSuccess'));
setIsSubscriptionChecked(false);
form.setValue('subscribed', false);
} else {
const errorMessage = unsubscribeResult?.data?.error || t('newsletter.unsubscribeFail');
toast.error(errorMessage);
setError(errorMessage);
// Reset checkbox if unsubscription failed
form.setValue('subscribed', true);
}
}
} catch (err) {
console.error('Newsletter subscription error:', err);
setError(t('newsletter.error'));
toast.error(t('newsletter.error'));
// Reset form to previous state on error
form.setValue('subscribed', isSubscriptionChecked);
} finally {
setIsUpdating(false);
}
};
return (
<Card className="max-w-md md:max-w-lg overflow-hidden">
<CardHeader>
<CardTitle className="text-lg font-bold">
{t('newsletter.title')}
</CardTitle>
<CardDescription>
{t('newsletter.description')}
</CardDescription>
</CardHeader>
<Form {...form}>
<form>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="subscribed"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md p-4">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={(checked) => {
field.onChange(checked);
handleSubscriptionChange(checked === true);
}}
disabled={isUpdating}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>
{t('newsletter.checkboxLabel')}
</FormLabel>
<p className="text-sm text-muted-foreground">
{t('newsletter.checkboxDescription')}
</p>
</div>
</FormItem>
)}
/>
<FormError message={error} />
</CardContent>
<CardFooter className="px-6 py-4 bg-muted rounded-none">
<p className="text-sm text-muted-foreground">
{t('newsletter.hint')}
</p>
</CardFooter>
</form>
</Form>
</Card>
);
}

View File

@ -11,6 +11,7 @@ import { Routes } from '@/routes';
import { MenuItem, NestedMenuItem, WebsiteConfig } from '@/types';
import {
AudioLinesIcon,
BellIcon,
BotIcon,
BuildingIcon,
ChartNoAxesCombinedIcon,
@ -407,6 +408,12 @@ export function getSidebarMainLinks(t: TranslationFunction): NestedMenuItem[] {
icon: <CreditCardIcon className="site-4 shrink-0" />,
href: Routes.SettingsBilling,
external: false,
},
{
title: t('Dashboard.sidebar.settings.items.notification.title'),
icon: <BellIcon className="site-4 shrink-0" />,
href: Routes.SettingsNotification,
external: false,
}
],
},

View File

@ -1,3 +1,4 @@
// export the subscribe and unsubscribe functions
export { subscribe } from './newsletter';
export { unsubscribe } from './newsletter';
export { isSubscribed } from './newsletter';

View File

@ -1,4 +1,4 @@
import { subscribeNewsletter, unsubscribeNewsletter } from './provider/resend';
import { subscribeNewsletter, unsubscribeNewsletter, checkSubscribeStatus } from './provider/resend';
export const subscribe = async (email: string) => {
const subscribed = await subscribeNewsletter({ email });
@ -9,3 +9,8 @@ export const unsubscribe = async (email: string) => {
const unsubscribed = await unsubscribeNewsletter({ email });
return unsubscribed;
};
export const isSubscribed = async (email: string) => {
const subscribed = await checkSubscribeStatus({ email });
return subscribed;
};

View File

@ -1,4 +1,4 @@
import { SubscribeNewsletterHandler, UnsubscribeNewsletterHandler } from '@/newsletter/types';
import { CheckSubscribeStatusHandler, SubscribeNewsletterHandler, UnsubscribeNewsletterHandler } from '@/newsletter/types';
import { Resend } from 'resend';
const apiKey = process.env.RESEND_API_KEY || 'test_api_key';
@ -48,4 +48,35 @@ export const unsubscribeNewsletter: UnsubscribeNewsletterHandler = async ({ emai
console.log('Unsubscribed newsletter', email);
return true;
}
};
};
export const checkSubscribeStatus: CheckSubscribeStatusHandler = async ({ email }) => {
if (!process.env.RESEND_API_KEY || !process.env.RESEND_AUDIENCE_ID) {
console.warn('RESEND_API_KEY or RESEND_AUDIENCE_ID not set, skipping check subscribe status');
return false;
}
try {
// First, list all contacts to find the one with the matching email
const listResult = await resend.contacts.list({ audienceId });
if (listResult.error) {
console.error('Error listing contacts:', listResult.error);
return false;
}
// Check if the contact with the given email exists in the list
// We need to check if data exists and is an array
if (listResult.data && Array.isArray(listResult.data)) {
// Now we can safely use array methods
return listResult.data.some(contact =>
contact.email === email && contact.unsubscribed === false
);
}
return false;
} catch (error) {
console.error('Error checking subscription status:', error);
return false;
}
};

View File

@ -6,14 +6,21 @@ export interface UnsubscribeNewsletterProps {
email: string;
}
export interface CheckSubscribeStatusProps {
email: string;
}
export type SubscribeNewsletterHandler = (params: SubscribeNewsletterProps) => Promise<boolean>;
export type UnsubscribeNewsletterHandler = (params: UnsubscribeNewsletterProps) => Promise<boolean>;
export type CheckSubscribeStatusHandler = (params: CheckSubscribeStatusProps) => Promise<boolean>;
/**
* Newsletter provider, currently only Resend is supported
*/
export interface NewsletterProvider {
subscribe: SubscribeNewsletterHandler;
unsubscribe: UnsubscribeNewsletterHandler;
checkSubscribeStatus: CheckSubscribeStatusHandler;
}

View File

@ -30,6 +30,7 @@ export enum Routes {
Dashboard = '/dashboard',
SettingsAccount = '/settings/account',
SettingsBilling = '/settings/billing',
SettingsNotification = '/settings/notification',
AIText = '/ai/text',
AIImage = '/ai/image',