feat(marketing): Add marketing management features and localization updates

This commit is contained in:
web
2025-08-08 08:59:19 -07:00
committed by speakeloudest
parent 47d19d1b44
commit ec3133e0c8
53 changed files with 2962 additions and 401 deletions
@@ -1,5 +1,6 @@
'use client';
import { createBatchSendEmailTask, getPreSendEmailCount } from '@/services/admin/marketing';
import { zodResolver } from '@hookform/resolvers/zod';
import { Button } from '@workspace/ui/components/button';
import {
@@ -33,49 +34,42 @@ import { Textarea } from '@workspace/ui/components/textarea';
import { MarkdownEditor } from '@workspace/ui/custom-components/editor';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { Icon } from '@workspace/ui/custom-components/icon';
import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
const emailBroadcastSchema = z.object({
subject: z.string().min(1, 'Email subject cannot be empty'),
content: z.string().min(1, 'Email content cannot be empty'),
// Send settings
additional_emails: z
.string()
.optional()
.refine(
(value) => {
if (!value || value.trim() === '') return true;
const emails = value.split('\n').filter((email) => email.trim() !== '');
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emails.every((email) => emailRegex.test(email.trim()));
},
{
message: 'Please enter valid email addresses, one per line',
},
),
// Send time settings
scheduled_time: z.string().optional(),
user_filters: z.object({
subscription_status: z.string().optional(),
registration_date_from: z.string().optional(),
registration_date_to: z.string().optional(),
user_groups: z.array(z.string()).default([]),
}),
rate_limit: z.object({
email_interval_seconds: z
.number()
.min(1, 'Email interval (seconds) cannot be less than 1')
.default(1),
daily_limit: z.number().min(1, 'Daily limit must be at least 1').default(1000),
}),
});
type EmailBroadcastFormData = z.infer<typeof emailBroadcastSchema>;
export default function EmailBroadcastForm() {
const t = useTranslations('marketing');
// Define schema with internationalized error messages
const emailBroadcastSchema = z.object({
subject: z.string().min(1, t('subject') + ' ' + t('cannotBeEmpty')),
content: z.string().min(1, t('content') + ' ' + t('cannotBeEmpty')),
scope: z.string().default('all'),
register_start_time: z.string().optional(),
register_end_time: z.string().optional(),
additional: z
.string()
.optional()
.refine(
(value) => {
if (!value || value.trim() === '') return true;
const emails = value.split('\n').filter((email) => email.trim() !== '');
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emails.every((email) => emailRegex.test(email.trim()));
},
{
message: t('pleaseEnterValidEmailAddresses'),
},
),
scheduled: z.string().optional(),
interval: z.number().min(0.1, t('emailIntervalMinimum')).optional(),
limit: z.number().min(1, t('dailyLimit')).optional(),
});
type EmailBroadcastFormData = z.infer<typeof emailBroadcastSchema>;
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [estimatedRecipients, setEstimatedRecipients] = useState<{
@@ -89,68 +83,71 @@ export default function EmailBroadcastForm() {
defaultValues: {
subject: '',
content: '',
additional_emails: '',
scheduled_time: '',
user_filters: {
subscription_status: 'all',
registration_date_from: '',
registration_date_to: '',
user_groups: [],
},
rate_limit: {
email_interval_seconds: 1,
daily_limit: 1000,
},
scope: 'all',
register_start_time: '',
register_end_time: '',
additional: '',
scheduled: '',
interval: 1,
limit: 1000,
},
});
// Calculate recipient count
const calculateRecipients = () => {
const calculateRecipients = async () => {
const formData = form.getValues();
// Simulate user data statistics (should call API in real implementation)
let userCount = 0;
try {
// Call API to get actual recipient count
const scope = formData.scope || 'all';
const sendingScope = formData.user_filters.subscription_status;
if (sendingScope === 'skip') {
// Send only to additional emails
userCount = 0;
} else {
let baseCount = 1500;
// Convert dates to timestamps if they exist
let register_start_time: number = 0;
let register_end_time: number = 0;
if (sendingScope === 'active') {
baseCount = Math.floor(baseCount * 0.3); // 30% active subscription users
} else if (sendingScope === 'expired') {
baseCount = Math.floor(baseCount * 0.2); // 20% expired subscription users
} else if (sendingScope === 'none') {
baseCount = Math.floor(baseCount * 0.5); // 50% no subscription users
}
// If 'all' or empty, keep baseCount unchanged (all platform users)
// Date filter impact (simplified calculation)
if (
formData.user_filters.registration_date_from ||
formData.user_filters.registration_date_to
) {
baseCount = Math.floor(baseCount * 0.7); // Estimate about 70% after date filtering
if (formData.register_start_time) {
register_start_time = Math.floor(new Date(formData.register_start_time).getTime());
}
userCount = baseCount;
if (formData.register_end_time) {
register_end_time = Math.floor(new Date(formData.register_end_time).getTime());
}
const response = await getPreSendEmailCount({
scope,
register_start_time,
register_end_time,
});
const userCount = response.data?.data?.count || 0;
// Calculate additional email count
const additionalEmails = formData.additional || '';
const additionalCount = additionalEmails
.split('\n')
.filter((email: string) => email.trim() !== '').length;
const total = userCount + additionalCount;
setEstimatedRecipients({
users: userCount,
additional: additionalCount,
total,
});
} catch (error) {
console.error('Failed to get recipient count:', error);
// Set to 0 if API fails, don't use fallback simulation
const additionalEmails = formData.additional || '';
const additionalCount = additionalEmails
.split('\n')
.filter((email: string) => email.trim() !== '').length;
setEstimatedRecipients({
users: 0,
additional: additionalCount,
total: additionalCount,
});
}
// Calculate additional email count
const additionalEmails = formData.additional_emails || '';
const additionalCount = additionalEmails
.split('\n')
.filter((email: string) => email.trim() !== '').length;
const total = userCount + additionalCount;
setEstimatedRecipients({
users: userCount,
additional: additionalCount,
total,
});
};
// Listen to form changes
@@ -158,42 +155,72 @@ export default function EmailBroadcastForm() {
// Use useEffect to respond to form changes
useEffect(() => {
calculateRecipients();
const debounceTimer = setTimeout(() => {
calculateRecipients();
}, 500); // Add debounce to avoid too frequent API calls
return () => clearTimeout(debounceTimer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
watchedValues.user_filters?.subscription_status,
watchedValues.user_filters?.registration_date_from,
watchedValues.user_filters?.registration_date_to,
watchedValues.additional_emails,
watchedValues.scope,
watchedValues.register_start_time,
watchedValues.register_end_time,
watchedValues.additional,
]);
const onSubmit = async (data: EmailBroadcastFormData) => {
setLoading(true);
try {
// Validate scheduled send time
if (data.scheduled_time && data.scheduled_time.trim() !== '') {
const scheduledDate = new Date(data.scheduled_time);
let scheduled: number | undefined;
if (data.scheduled && data.scheduled.trim() !== '') {
const scheduledDate = new Date(data.scheduled);
const now = new Date();
if (scheduledDate <= now) {
toast.error('Scheduled send time must be later than current time');
toast.error(t('scheduledSendTimeMustBeLater'));
return;
}
scheduled = Math.floor(scheduledDate.getTime());
}
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1000));
console.log('Email broadcast data:', data);
let register_start_time: number = 0;
let register_end_time: number = 0;
if (!data.scheduled_time || data.scheduled_time.trim() === '') {
toast.success('Email sent successfully');
if (data.register_start_time) {
register_start_time = Math.floor(new Date(data.register_start_time).getTime());
}
if (data.register_end_time) {
register_end_time = Math.floor(new Date(data.register_end_time).getTime());
}
// Prepare API request data
const requestData: API.CreateBatchSendEmailTaskRequest = {
subject: data.subject,
content: data.content,
scope: data.scope,
register_start_time,
register_end_time,
additional: data.additional || undefined,
scheduled,
interval: data.interval ? data.interval * 1000 : undefined, // Convert seconds to milliseconds
limit: data.limit,
};
// Call API to create batch send email task
await createBatchSendEmailTask(requestData);
if (!data.scheduled || data.scheduled.trim() === '') {
toast.success(t('emailBroadcastTaskCreatedSuccessfully'));
} else {
toast.success('Email added to scheduled send queue');
toast.success(t('emailAddedToScheduledQueue'));
}
form.reset();
setOpen(false);
} catch (error) {
toast.error('Send failed, please try again');
console.error('Email broadcast failed:', error);
toast.error(t('sendFailed'));
} finally {
setLoading(false);
}
@@ -208,8 +235,10 @@ export default function EmailBroadcastForm() {
<Icon icon='mdi:email-send' className='text-primary h-5 w-5' />
</div>
<div className='flex-1'>
<p className='font-medium'>Email Broadcast</p>
<p className='text-muted-foreground text-sm'>Create new email broadcast campaign</p>
<p className='font-medium'>{t('emailBroadcast')}</p>
<p className='text-muted-foreground text-sm'>
{t('createNewEmailBroadcastCampaign')}
</p>
</div>
</div>
<Icon icon='mdi:chevron-right' className='size-6' />
@@ -217,7 +246,7 @@ export default function EmailBroadcastForm() {
</SheetTrigger>
<SheetContent className='w-[700px] max-w-full md:max-w-screen-lg'>
<SheetHeader>
<SheetTitle>Create Email Broadcast</SheetTitle>
<SheetTitle>{t('createBroadcast')}</SheetTitle>
</SheetHeader>
<ScrollArea className='-mx-6 h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))] px-6'>
<Form {...form}>
@@ -228,8 +257,8 @@ export default function EmailBroadcastForm() {
>
<Tabs defaultValue='content' className='space-y-2'>
<TabsList className='grid w-full grid-cols-2'>
<TabsTrigger value='content'>Email Content</TabsTrigger>
<TabsTrigger value='settings'>Send Settings</TabsTrigger>
<TabsTrigger value='content'>{t('content')}</TabsTrigger>
<TabsTrigger value='settings'>{t('sendSettings')}</TabsTrigger>
</TabsList>
{/* Email Content Tab */}
<TabsContent value='content' className='space-y-2'>
@@ -238,9 +267,12 @@ export default function EmailBroadcastForm() {
name='subject'
render={({ field }) => (
<FormItem>
<FormLabel>Email Subject</FormLabel>
<FormLabel>{t('subject')}</FormLabel>
<FormControl>
<Input placeholder='Please enter email subject' {...field} />
<Input
placeholder={`${t('pleaseEnter')} ${t('subject').toLowerCase()}`}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -252,7 +284,7 @@ export default function EmailBroadcastForm() {
name='content'
render={({ field }) => (
<FormItem>
<FormLabel>Email Content</FormLabel>
<FormLabel>{t('content')}</FormLabel>
<FormControl>
<MarkdownEditor
value={field.value}
@@ -261,9 +293,7 @@ export default function EmailBroadcastForm() {
}}
/>
</FormControl>
<FormDescription>
Use Markdown editor to write email content with preview functionality
</FormDescription>
<FormDescription>{t('useMarkdownEditor')}</FormDescription>
<FormMessage />
</FormItem>
)}
@@ -276,32 +306,27 @@ export default function EmailBroadcastForm() {
<div className='grid grid-cols-2 items-center gap-4'>
<FormField
control={form.control}
name='user_filters.subscription_status'
name='scope'
render={({ field }) => (
<FormItem>
<FormLabel>Send Scope</FormLabel>
<FormLabel>{t('sendScope')}</FormLabel>
<Select onValueChange={field.onChange} value={field.value || 'all'}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder='Select send scope' />
<SelectValue placeholder={t('selectSendScope')} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value='all'>All platform users</SelectItem>
<SelectItem value='active'>Active subscription users only</SelectItem>
<SelectItem value='all'>{t('allUsers')}</SelectItem>
<SelectItem value='active'>{t('subscribedUsersOnly')}</SelectItem>
<SelectItem value='expired'>
Expired subscription users only
</SelectItem>
<SelectItem value='none'>No subscription users only</SelectItem>
<SelectItem value='skip'>
Additional emails only (skip platform users)
{t('expiredSubscriptionUsersOnly')}
</SelectItem>
<SelectItem value='none'>{t('noSubscriptionUsersOnly')}</SelectItem>
<SelectItem value='skip'>{t('specificUsersOnly')}</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Choose the user scope for email sending. Select &ldquo;Additional emails
only&rdquo; to send only to the email addresses filled below
</FormDescription>
<FormDescription>{t('sendScopeDescription')}</FormDescription>
</FormItem>
)}
/>
@@ -309,12 +334,12 @@ export default function EmailBroadcastForm() {
{/* Estimated recipients info */}
<div className='flex justify-end'>
<div className='border-l-primary bg-primary/10 border-l-4 px-4 py-3 text-sm'>
<span className='text-muted-foreground'>Estimated recipients: </span>
<span className='text-muted-foreground'>{t('estimatedRecipients')}: </span>
<span className='text-primary text-lg font-medium'>
{estimatedRecipients.total}
</span>
<span className='text-muted-foreground ml-2 text-xs'>
(users: {estimatedRecipients.users}, additional:{' '}
({t('users')}: {estimatedRecipients.users}, {t('additional')}:{' '}
{estimatedRecipients.additional})
</span>
</div>
@@ -324,41 +349,37 @@ export default function EmailBroadcastForm() {
<div className='grid grid-cols-2 gap-4'>
<FormField
control={form.control}
name='user_filters.registration_date_from'
name='register_start_time'
render={({ field }) => (
<FormItem>
<FormLabel>Registration Start Date</FormLabel>
<FormLabel>{t('registrationStartDate')}</FormLabel>
<FormControl>
<EnhancedInput
type='datetime-local'
disabled={form.watch('user_filters.subscription_status') === 'skip'}
disabled={form.watch('scope') === 'skip'}
value={field.value}
onValueChange={field.onChange}
/>
</FormControl>
<FormDescription>
Include users registered on or after this date
</FormDescription>
<FormDescription>{t('includeUsersRegisteredAfter')}</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name='user_filters.registration_date_to'
name='register_end_time'
render={({ field }) => (
<FormItem>
<FormLabel>Registration End Date</FormLabel>
<FormLabel>{t('registrationEndDate')}</FormLabel>
<FormControl>
<EnhancedInput
type='datetime-local'
disabled={form.watch('user_filters.subscription_status') === 'skip'}
disabled={form.watch('scope') === 'skip'}
value={field.value}
onValueChange={field.onChange}
/>
</FormControl>
<FormDescription>
Include users registered on or before this date
</FormDescription>
<FormDescription>{t('includeUsersRegisteredBefore')}</FormDescription>
</FormItem>
)}
/>
@@ -367,21 +388,18 @@ export default function EmailBroadcastForm() {
{/* Additional recipients */}
<FormField
control={form.control}
name='additional_emails'
name='additional'
render={({ field }) => (
<FormItem>
<FormLabel>Additional Recipient Emails</FormLabel>
<FormLabel>{t('additionalRecipientEmails')}</FormLabel>
<FormControl>
<Textarea
placeholder={`Please enter additional recipient emails, one per line, for example:\nexample1@domain.com\nexample2@domain.com\nexample3@domain.com`}
placeholder={`${t('pleaseEnter')}${t('additionalRecipientEmails').toLowerCase()}${t('onePerLine')}for example:\nexample1@domain.com\nexample2@domain.com\nexample3@domain.com`}
className='min-h-[120px] font-mono text-sm'
{...field}
/>
</FormControl>
<FormDescription>
These emails will receive the email additionally, not affected by the user
filter conditions above
</FormDescription>
<FormDescription>{t('additionalRecipientsDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
@@ -390,21 +408,19 @@ export default function EmailBroadcastForm() {
{/* Send time settings */}
<FormField
control={form.control}
name='scheduled_time'
name='scheduled'
render={({ field }) => (
<FormItem>
<FormLabel>Scheduled Send</FormLabel>
<FormLabel>{t('scheduledSend')}</FormLabel>
<FormControl>
<EnhancedInput
type='datetime-local'
placeholder='Leave empty for immediate send'
placeholder={t('leaveEmptyForImmediateSend')}
value={field.value}
onValueChange={field.onChange}
/>
</FormControl>
<FormDescription>
Select send time, leave empty for immediate send
</FormDescription>
<FormDescription>{t('selectSendTime')}</FormDescription>
<FormMessage />
</FormItem>
)}
@@ -414,43 +430,42 @@ export default function EmailBroadcastForm() {
<div className='grid grid-cols-2 gap-4'>
<FormField
control={form.control}
name='rate_limit.email_interval_seconds'
name='interval'
render={({ field }) => (
<FormItem>
<FormLabel>Email Interval (seconds)</FormLabel>
<FormLabel>{t('emailInterval')}</FormLabel>
<FormControl>
<Input
type='number'
min={0}
min={1}
step={0.1}
placeholder='1'
{...field}
onChange={(e) => field.onChange(parseFloat(e.target.value) || 1)}
/>
</FormControl>
<FormDescription>Interval time between each email</FormDescription>
<FormDescription>{t('intervalTimeBetweenEmails')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='rate_limit.daily_limit'
name='limit'
render={({ field }) => (
<FormItem>
<FormLabel>Daily Send Limit</FormLabel>
<FormLabel>{t('dailySendLimit')}</FormLabel>
<FormControl>
<Input
type='number'
min={1}
step={1}
placeholder='1000'
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value) || 1000)}
/>
</FormControl>
<FormDescription>
Maximum number of emails to send per day
</FormDescription>
<FormDescription>{t('maximumNumberPerDay')}</FormDescription>
<FormMessage />
</FormItem>
)}
@@ -463,15 +478,15 @@ export default function EmailBroadcastForm() {
</ScrollArea>
<SheetFooter className='flex flex-row items-center justify-end gap-2 pt-3'>
<Button variant='outline' onClick={() => setOpen(false)}>
Cancel
{t('cancel')}
</Button>
<Button type='submit' form='broadcast-form' disabled={loading}>
{loading && <Icon icon='mdi:loading' className='mr-2 h-4 w-4 animate-spin' />}
{loading
? 'Processing...'
: !form.watch('scheduled_time') || form.watch('scheduled_time')?.trim() === ''
? 'Send Now'
: 'Schedule Send'}
? t('processing')
: !form.watch('scheduled') || form.watch('scheduled')?.trim() === ''
? t('sendNow')
: t('scheduleSend')}
</Button>
</SheetFooter>
</SheetContent>
@@ -1,225 +0,0 @@
'use client';
import { ProTable, ProTableActions } from '@/components/pro-table';
import { Badge } from '@workspace/ui/components/badge';
import { Button } from '@workspace/ui/components/button';
import { ScrollArea } from '@workspace/ui/components/scroll-area';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@workspace/ui/components/sheet';
import { Icon } from '@workspace/ui/custom-components/icon';
import { formatDate } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl';
import { useRef, useState } from 'react';
interface EmailLog extends Record<string, unknown> {
id: number;
subject: string;
recipient_email: string;
status: 'pending' | 'sent' | 'failed';
sent_at?: string;
error_message?: string;
broadcast_id: number;
}
interface GetEmailLogsParams extends Record<string, unknown> {
page: number;
size: number;
status?: string;
search?: string;
}
// Mock API function
const getEmailLogs = async (params: GetEmailLogsParams) => {
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 500));
// Mock data
const mockData: EmailLog[] = [
{
id: 1,
subject: 'New Feature Release Notification',
recipient_email: 'user1@example.com',
status: 'sent',
sent_at: '2024-01-15T10:05:00Z',
broadcast_id: 1,
},
{
id: 2,
subject: 'New Feature Release Notification',
recipient_email: 'user2@example.com',
status: 'sent',
sent_at: '2024-01-15T10:05:30Z',
broadcast_id: 1,
},
{
id: 3,
subject: 'New Feature Release Notification',
recipient_email: 'user3@example.com',
status: 'failed',
error_message: 'Invalid email address',
broadcast_id: 1,
},
{
id: 4,
subject: 'System Maintenance Notice',
recipient_email: 'user4@example.com',
status: 'sent',
sent_at: '2024-01-14T15:35:00Z',
broadcast_id: 2,
},
{
id: 5,
subject: 'System Maintenance Notice',
recipient_email: 'user5@example.com',
status: 'pending',
broadcast_id: 2,
},
];
return {
data: {
data: {
list: mockData,
total: mockData.length,
},
},
};
};
export default function BroadcastLogsTable() {
const t = useTranslations('marketing');
const [open, setOpen] = useState(false);
const ref = useRef<ProTableActions>(null);
const getStatusBadge = (status: string) => {
const statusMap = {
pending: { variant: 'secondary' as const, label: 'Pending' },
sent: { variant: 'default' as const, label: 'Sent' },
failed: { variant: 'destructive' as const, label: 'Failed' },
};
const statusInfo = statusMap[status as keyof typeof statusMap] || statusMap.pending;
return <Badge variant={statusInfo.variant}>{statusInfo.label}</Badge>;
};
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<div className='flex cursor-pointer items-center justify-between transition-colors'>
<div className='flex items-center gap-3'>
<div className='bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg'>
<Icon icon='mdi:email-newsletter' className='text-primary h-5 w-5' />
</div>
<div className='flex-1'>
<p className='font-medium'>Broadcast Logs</p>
<p className='text-muted-foreground text-sm'>
View email send records and detailed status
</p>
</div>
</div>
<Icon icon='mdi:chevron-right' className='size-6' />
</div>
</SheetTrigger>
<SheetContent className='w-[90vw] max-w-full md:max-w-screen-xl'>
<SheetHeader>
<SheetTitle>Broadcast Logs</SheetTitle>
</SheetHeader>
<ScrollArea className='-mx-6 h-[calc(100dvh-48px-36px-env(safe-area-inset-top))] px-6'>
<div className='pt-4'>
<ProTable<EmailLog, GetEmailLogsParams>
action={ref}
columns={[
{
accessorKey: 'id',
header: 'ID',
size: 80,
},
{
accessorKey: 'subject',
header: 'Subject',
size: 200,
cell: ({ row }) => (
<div
className='max-w-[200px] truncate'
title={row.getValue('subject') as string}
>
{row.getValue('subject') as string}
</div>
),
},
{
accessorKey: 'recipient_email',
header: 'Recipient Email',
size: 200,
},
{
accessorKey: 'status',
header: 'Status',
size: 100,
cell: ({ row }) => getStatusBadge(row.getValue('status') as string),
},
{
accessorKey: 'sent_at',
header: 'Sent At',
size: 150,
cell: ({ row }) => {
const sentAt = row.getValue('sent_at') as string;
return sentAt ? formatDate(new Date(sentAt)) : '--';
},
},
{
accessorKey: 'error_message',
header: 'Error Message',
size: 200,
cell: ({ row }) => {
const error = row.getValue('error_message') as string;
return error ? <span className='text-sm text-red-600'>{error}</span> : '--';
},
},
]}
request={async (pagination, filter) => {
const { data } = await getEmailLogs({
...pagination,
...filter,
});
return {
list: data.data?.list || [],
total: data.data?.total || 0,
};
}}
params={[
{
key: 'status',
placeholder: 'Status',
options: [
{ label: 'Pending', value: 'pending' },
{ label: 'Sent', value: 'sent' },
{ label: 'Failed', value: 'failed' },
],
},
{
key: 'search',
placeholder: 'Recipient Email',
},
]}
actions={{
render: (row) => {
return [
<Button key='view' variant='outline' size='sm'>
View
</Button>,
];
},
}}
/>
</div>
</ScrollArea>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,302 @@
'use client';
import { ProTable } from '@/components/pro-table';
import {
getBatchSendEmailTaskList,
getBatchSendEmailTaskStatus,
stopBatchSendEmailTask,
} from '@/services/admin/marketing';
import { Badge } from '@workspace/ui/components/badge';
import { Button } from '@workspace/ui/components/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@workspace/ui/components/dialog';
import { ScrollArea } from '@workspace/ui/components/scroll-area';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@workspace/ui/components/sheet';
import { Icon } from '@workspace/ui/custom-components/icon';
import { formatDate } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { toast } from 'sonner';
export default function EmailTaskManager() {
const t = useTranslations('marketing');
const [refreshing, setRefreshing] = useState<Record<number, boolean>>({});
const [selectedTask, setSelectedTask] = useState<API.BatchSendEmailTask | null>(null);
// Get task status
const refreshTaskStatus = async (taskId: number) => {
setRefreshing((prev) => ({ ...prev, [taskId]: true }));
try {
const response = await getBatchSendEmailTaskStatus({
id: taskId,
});
const taskStatus = response.data?.data;
if (taskStatus) {
// Just show success message, ProTable will auto-refresh
toast.success(t('taskStatusRefreshed'));
}
} catch (error) {
console.error('Failed to refresh task status:', error);
toast.error(t('failedToRefreshTaskStatus'));
} finally {
setRefreshing((prev) => ({ ...prev, [taskId]: false }));
}
};
// Stop task
const stopTask = async (taskId: number) => {
try {
await stopBatchSendEmailTask({
id: taskId,
});
toast.success(t('taskStoppedSuccessfully'));
await refreshTaskStatus(taskId);
} catch (error) {
console.error('Failed to stop task:', error);
toast.error(t('failedToStopTask'));
}
};
const getStatusBadge = (status: number) => {
const statusConfig = {
0: { label: t('notStarted'), variant: 'secondary' as const },
1: { label: t('inProgress'), variant: 'default' as const },
2: { label: t('completed'), variant: 'default' as const },
};
const config = statusConfig[status as keyof typeof statusConfig] || {
label: `${t('status')} ${status}`,
variant: 'secondary' as const,
};
return <Badge variant={config.variant}>{config.label}</Badge>;
};
return (
<Sheet>
<SheetTrigger asChild>
<div className='flex cursor-pointer items-center justify-between transition-colors'>
<div className='flex items-center gap-3'>
<div className='bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg'>
<Icon icon='mdi:email-multiple' className='text-primary h-5 w-5' />
</div>
<div className='flex-1'>
<p className='font-medium'>{t('emailTaskManager')}</p>
<p className='text-muted-foreground text-sm'>
{t('viewAndManageEmailBroadcastTasks')}
</p>
</div>
</div>
<Icon icon='mdi:chevron-right' className='size-6' />
</div>
</SheetTrigger>
<SheetContent className='w-[1000px] max-w-full md:max-w-screen-lg'>
<SheetHeader>
<SheetTitle>{t('emailBroadcastTasks')}</SheetTitle>
</SheetHeader>
<ScrollArea className='-mx-6 h-[calc(100dvh-48px-36px-env(safe-area-inset-top))] px-6'>
<div className='mt-4 space-y-4'>
<ProTable<API.BatchSendEmailTask, API.GetBatchSendEmailTaskListParams>
columns={[
{
accessorKey: 'subject',
header: t('subject'),
size: 200,
cell: ({ row }) => (
<div
className='max-w-[200px] truncate font-medium'
title={row.getValue('subject') as string}
>
{row.getValue('subject') as string}
</div>
),
},
{
accessorKey: 'scope',
header: t('recipientType'),
size: 120,
cell: ({ row }) => {
const scope = row.getValue('scope') as string;
const scopeLabels = {
all: t('allUsers'),
active: t('subscribedUsers'),
expired: t('expiredUsers'),
none: t('nonSubscribers'),
skip: t('specificUsers'),
};
return scopeLabels[scope as keyof typeof scopeLabels] || scope;
},
},
{
accessorKey: 'status',
header: t('status'),
size: 100,
cell: ({ row }) => getStatusBadge(row.getValue('status') as number),
},
{
accessorKey: 'progress',
header: t('progress'),
size: 150,
cell: ({ row }) => {
const task = row.original as API.BatchSendEmailTask;
const progress = task.total > 0 ? (task.current / task.total) * 100 : 0;
return (
<div className='space-y-1'>
<div className='flex justify-between text-sm'>
<span>
{task.current} / {task.total}
</span>
<span>{progress.toFixed(1)}%</span>
</div>
<div className='bg-muted h-2 overflow-hidden rounded-full'>
<div
className='bg-primary h-full transition-all duration-300'
style={{ width: `${progress}%` }}
/>
</div>
</div>
);
},
},
{
accessorKey: 'created_at',
header: t('createdAt'),
size: 150,
cell: ({ row }) => {
const createdAt = row.getValue('created_at') as number;
return formatDate(createdAt);
},
},
{
accessorKey: 'scheduled',
header: t('sendTime'),
size: 150,
cell: ({ row }) => {
const scheduled = row.getValue('scheduled') as number;
return scheduled && scheduled > 0 ? formatDate(scheduled) : '--';
},
},
]}
request={async (pagination, filters) => {
const response = await getBatchSendEmailTaskList({
...filters,
page: pagination.page,
size: pagination.size,
});
return {
list: response.data?.data?.list || [],
total: response.data?.data?.total || 0,
};
}}
params={[
{
key: 'status',
placeholder: t('status'),
options: [
{ label: t('notStarted'), value: '0' },
{ label: t('inProgress'), value: '1' },
{ label: t('completed'), value: '2' },
],
},
{
key: 'scope',
placeholder: t('sendScope'),
options: [
{ label: t('allUsers'), value: 'all' },
{ label: t('subscribedUsers'), value: 'active' },
{ label: t('expiredUsers'), value: 'expired' },
{ label: t('nonSubscribers'), value: 'none' },
{ label: t('specificUsers'), value: 'skip' },
],
},
]}
actions={{
render: (row) => {
return [
<Dialog key='view-content'>
<DialogTrigger asChild>
<Button
variant='outline'
size='icon'
onClick={() => setSelectedTask(row as API.BatchSendEmailTask)}
>
<Icon icon='mdi:eye' />
</Button>
</DialogTrigger>
<DialogContent className='max-h-[80vh] max-w-4xl'>
<DialogHeader>
<DialogTitle>{t('emailContent')}</DialogTitle>
</DialogHeader>
<ScrollArea className='h-[60vh] pr-4'>
{selectedTask && (
<div className='space-y-4'>
<div>
<h4 className='text-muted-foreground mb-2 text-sm font-medium'>
{t('subject')}
</h4>
<p className='font-medium'>{selectedTask.subject}</p>
</div>
<div>
<h4 className='text-muted-foreground mb-2 text-sm font-medium'>
{t('content')}
</h4>
<div
className='prose prose-sm max-w-none'
dangerouslySetInnerHTML={{ __html: selectedTask.content }}
/>
</div>
{selectedTask.additional && (
<div>
<h4 className='text-muted-foreground mb-2 text-sm font-medium'>
{t('additionalRecipients')}
</h4>
<p className='text-sm'>{selectedTask.additional}</p>
</div>
)}
</div>
)}
</ScrollArea>
</DialogContent>
</Dialog>,
<Button
key='refresh'
variant='outline'
size='icon'
onClick={() => refreshTaskStatus(row.id)}
disabled={refreshing[row.id]}
>
{refreshing[row.id] && (
<Icon icon='mdi:loading' className='mr-2 h-3 w-3 animate-spin' />
)}
<Icon icon='mdi:refresh' className='h-3 w-3' />
</Button>,
...([0, 1].includes(row.status)
? [
<Button key='stop' variant='destructive' onClick={() => stopTask(row.id)}>
{t('stop')}
</Button>,
]
: []),
];
},
}}
/>
</div>
</ScrollArea>
</SheetContent>
</Sheet>
);
}
+3 -3
View File
@@ -3,15 +3,15 @@
import { Table, TableBody, TableCell, TableRow } from '@workspace/ui/components/table';
import { useTranslations } from 'next-intl';
import EmailBroadcastForm from './email/broadcast-form';
import BroadcastLogsTable from './email/logs-table';
import EmailTaskManager from './email/task-manager';
export default function MarketingPage() {
const t = useTranslations('marketing');
const formSections = [
{
title: 'Email Marketing',
forms: [{ component: EmailBroadcastForm }, { component: BroadcastLogsTable }],
title: t('emailMarketing'),
forms: [{ component: EmailBroadcastForm }, { component: EmailTaskManager }],
},
];