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:
parent
6413fcd33c
commit
5607b57bd1
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -349,6 +349,22 @@
|
||||
},
|
||||
"billing": {
|
||||
"title": "账单"
|
||||
},
|
||||
"notification": {
|
||||
"title": "通知",
|
||||
"newsletter": {
|
||||
"title": "订阅",
|
||||
"description": "管理您的邮件列表订阅偏好",
|
||||
"checkboxLabel": "订阅邮件列表",
|
||||
"checkboxDescription": "通过邮件接收更新、新闻和特别优惠",
|
||||
"hint": "您可以随时更改订阅偏好",
|
||||
"emailRequired": "订阅邮件列表需要邮箱",
|
||||
"subscribeSuccess": "成功订阅邮件列表",
|
||||
"subscribeFail": "订阅邮件列表失败",
|
||||
"unsubscribeSuccess": "成功取消订阅邮件列表",
|
||||
"unsubscribeFail": "取消订阅邮件列表失败",
|
||||
"error": "更新订阅时发生错误"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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',
|
||||
};
|
||||
}
|
||||
});
|
32
src/app/[locale]/(dashborad)/settings/notification/page.tsx
Normal file
32
src/app/[locale]/(dashborad)/settings/notification/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
@ -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">
|
||||
|
@ -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"
|
||||
|
205
src/components/settings/notification/newsletter-form-card.tsx
Normal file
205
src/components/settings/notification/newsletter-form-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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,
|
||||
}
|
||||
],
|
||||
},
|
||||
|
@ -1,3 +1,4 @@
|
||||
// export the subscribe and unsubscribe functions
|
||||
export { subscribe } from './newsletter';
|
||||
export { unsubscribe } from './newsletter';
|
||||
export { isSubscribed } from './newsletter';
|
@ -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;
|
||||
};
|
||||
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -30,6 +30,7 @@ export enum Routes {
|
||||
Dashboard = '/dashboard',
|
||||
SettingsAccount = '/settings/account',
|
||||
SettingsBilling = '/settings/billing',
|
||||
SettingsNotification = '/settings/notification',
|
||||
|
||||
AIText = '/ai/text',
|
||||
AIImage = '/ai/image',
|
||||
|
Loading…
Reference in New Issue
Block a user