♻️ refactor: Refactoring and adding multiple features

This commit is contained in:
web
2025-08-05 06:42:39 -07:00
committed by speakeloudest
parent 5a2fa2e937
commit 47d19d1b44
106 changed files with 6893 additions and 4604 deletions
@@ -0,0 +1,480 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { Button } from '@workspace/ui/components/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@workspace/ui/components/form';
import { Input } from '@workspace/ui/components/input';
import { ScrollArea } from '@workspace/ui/components/scroll-area';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@workspace/ui/components/select';
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@workspace/ui/components/sheet';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@workspace/ui/components/tabs';
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 { 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 [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [estimatedRecipients, setEstimatedRecipients] = useState<{
users: number;
additional: number;
total: number;
}>({ users: 0, additional: 0, total: 0 });
const form = useForm<EmailBroadcastFormData>({
resolver: zodResolver(emailBroadcastSchema),
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,
},
},
});
// Calculate recipient count
const calculateRecipients = () => {
const formData = form.getValues();
// Simulate user data statistics (should call API in real implementation)
let userCount = 0;
const sendingScope = formData.user_filters.subscription_status;
if (sendingScope === 'skip') {
// Send only to additional emails
userCount = 0;
} else {
let baseCount = 1500;
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
}
userCount = baseCount;
}
// 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
const watchedValues = form.watch();
// Use useEffect to respond to form changes
useEffect(() => {
calculateRecipients();
// 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,
]);
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);
const now = new Date();
if (scheduledDate <= now) {
toast.error('Scheduled send time must be later than current time');
return;
}
}
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1000));
console.log('Email broadcast data:', data);
if (!data.scheduled_time || data.scheduled_time.trim() === '') {
toast.success('Email sent successfully');
} else {
toast.success('Email added to scheduled send queue');
}
form.reset();
setOpen(false);
} catch (error) {
toast.error('Send failed, please try again');
} finally {
setLoading(false);
}
};
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-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>
</div>
</div>
<Icon icon='mdi:chevron-right' className='size-6' />
</div>
</SheetTrigger>
<SheetContent className='w-[700px] max-w-full md:max-w-screen-lg'>
<SheetHeader>
<SheetTitle>Create Email Broadcast</SheetTitle>
</SheetHeader>
<ScrollArea className='-mx-6 h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))] px-6'>
<Form {...form}>
<form
id='broadcast-form'
onSubmit={form.handleSubmit(onSubmit)}
className='space-y-2 pt-4'
>
<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>
</TabsList>
{/* Email Content Tab */}
<TabsContent value='content' className='space-y-2'>
<FormField
control={form.control}
name='subject'
render={({ field }) => (
<FormItem>
<FormLabel>Email Subject</FormLabel>
<FormControl>
<Input placeholder='Please enter email subject' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='content'
render={({ field }) => (
<FormItem>
<FormLabel>Email Content</FormLabel>
<FormControl>
<MarkdownEditor
value={field.value}
onChange={(value) => {
form.setValue(field.name, value || '');
}}
/>
</FormControl>
<FormDescription>
Use Markdown editor to write email content with preview functionality
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</TabsContent>
{/* Send Settings Tab */}
<TabsContent value='settings' className='space-y-2'>
{/* Send scope and estimated recipients */}
<div className='grid grid-cols-2 items-center gap-4'>
<FormField
control={form.control}
name='user_filters.subscription_status'
render={({ field }) => (
<FormItem>
<FormLabel>Send Scope</FormLabel>
<Select onValueChange={field.onChange} value={field.value || 'all'}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder='Select send scope' />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value='all'>All platform users</SelectItem>
<SelectItem value='active'>Active subscription users only</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)
</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>
</FormItem>
)}
/>
{/* 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-primary text-lg font-medium'>
{estimatedRecipients.total}
</span>
<span className='text-muted-foreground ml-2 text-xs'>
(users: {estimatedRecipients.users}, additional:{' '}
{estimatedRecipients.additional})
</span>
</div>
</div>
</div>
<div className='grid grid-cols-2 gap-4'>
<FormField
control={form.control}
name='user_filters.registration_date_from'
render={({ field }) => (
<FormItem>
<FormLabel>Registration Start Date</FormLabel>
<FormControl>
<EnhancedInput
type='datetime-local'
disabled={form.watch('user_filters.subscription_status') === 'skip'}
value={field.value}
onValueChange={field.onChange}
/>
</FormControl>
<FormDescription>
Include users registered on or after this date
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name='user_filters.registration_date_to'
render={({ field }) => (
<FormItem>
<FormLabel>Registration End Date</FormLabel>
<FormControl>
<EnhancedInput
type='datetime-local'
disabled={form.watch('user_filters.subscription_status') === 'skip'}
value={field.value}
onValueChange={field.onChange}
/>
</FormControl>
<FormDescription>
Include users registered on or before this date
</FormDescription>
</FormItem>
)}
/>
</div>
{/* Additional recipients */}
<FormField
control={form.control}
name='additional_emails'
render={({ field }) => (
<FormItem>
<FormLabel>Additional Recipient Emails</FormLabel>
<FormControl>
<Textarea
placeholder={`Please enter additional recipient emails, one per line, 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>
<FormMessage />
</FormItem>
)}
/>
{/* Send time settings */}
<FormField
control={form.control}
name='scheduled_time'
render={({ field }) => (
<FormItem>
<FormLabel>Scheduled Send</FormLabel>
<FormControl>
<EnhancedInput
type='datetime-local'
placeholder='Leave empty for immediate send'
value={field.value}
onValueChange={field.onChange}
/>
</FormControl>
<FormDescription>
Select send time, leave empty for immediate send
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Send rate control */}
<div className='grid grid-cols-2 gap-4'>
<FormField
control={form.control}
name='rate_limit.email_interval_seconds'
render={({ field }) => (
<FormItem>
<FormLabel>Email Interval (seconds)</FormLabel>
<FormControl>
<Input
type='number'
min={0}
step={0.1}
placeholder='1'
{...field}
onChange={(e) => field.onChange(parseFloat(e.target.value) || 1)}
/>
</FormControl>
<FormDescription>Interval time between each email</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='rate_limit.daily_limit'
render={({ field }) => (
<FormItem>
<FormLabel>Daily Send Limit</FormLabel>
<FormControl>
<Input
type='number'
min={1}
placeholder='1000'
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value) || 1000)}
/>
</FormControl>
<FormDescription>
Maximum number of emails to send per day
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</TabsContent>
</Tabs>
</form>
</Form>
</ScrollArea>
<SheetFooter className='flex flex-row items-center justify-end gap-2 pt-3'>
<Button variant='outline' onClick={() => setOpen(false)}>
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'}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,225 @@
'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,41 @@
'use client';
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';
export default function MarketingPage() {
const t = useTranslations('marketing');
const formSections = [
{
title: 'Email Marketing',
forms: [{ component: EmailBroadcastForm }, { component: BroadcastLogsTable }],
},
];
return (
<div className='space-y-8'>
{formSections.map((section, sectionIndex) => (
<div key={sectionIndex}>
<h2 className='mb-4 text-lg font-semibold'>{section.title}</h2>
<Table>
<TableBody>
{section.forms.map((form, formIndex) => {
const FormComponent = form.component;
return (
<TableRow key={formIndex}>
<TableCell>
<FormComponent />
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
))}
</div>
);
}