diff --git a/apps/admin/app/dashboard/application/config.tsx b/apps/admin/app/dashboard/application/config.tsx index 785c8a3..14082a0 100644 --- a/apps/admin/app/dashboard/application/config.tsx +++ b/apps/admin/app/dashboard/application/config.tsx @@ -51,7 +51,7 @@ const formSchema = z.object({ type FormSchema = z.infer; export default function ConfigForm() { - const t = useTranslations('subscribe.app'); + const t = useTranslations('product.app'); const [open, setOpen] = useState(false); const [loading, setLoading] = useState(false); diff --git a/apps/admin/app/dashboard/application/form.tsx b/apps/admin/app/dashboard/application/form.tsx index 3de25d2..49e91f7 100644 --- a/apps/admin/app/dashboard/application/form.tsx +++ b/apps/admin/app/dashboard/application/form.tsx @@ -80,7 +80,7 @@ interface FormProps { export default function SubscribeAppForm< T extends API.CreateApplicationRequest | API.UpdateApplicationRequest, >({ trigger, title, loading, initialValues, onSubmit }: FormProps) { - const t = useTranslations('subscribe.app'); + const t = useTranslations('product.app'); const [open, setOpen] = useState(false); type FormSchema = z.infer; diff --git a/apps/admin/app/dashboard/application/page.tsx b/apps/admin/app/dashboard/application/page.tsx index 8e26977..cf68f19 100644 --- a/apps/admin/app/dashboard/application/page.tsx +++ b/apps/admin/app/dashboard/application/page.tsx @@ -17,7 +17,7 @@ import ConfigForm from './config'; import SubscribeAppForm from './form'; export default function SubscribeApp() { - const t = useTranslations('subscribe.app'); + const t = useTranslations('product.app'); const [loading, setLoading] = useState(false); const ref = useRef(null); diff --git a/apps/admin/app/dashboard/marketing/email/broadcast-form.tsx b/apps/admin/app/dashboard/marketing/email/broadcast-form.tsx new file mode 100644 index 0000000..466243d --- /dev/null +++ b/apps/admin/app/dashboard/marketing/email/broadcast-form.tsx @@ -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; + +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({ + 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 ( + + +
+
+
+ +
+
+

Email Broadcast

+

Create new email broadcast campaign

+
+
+ +
+
+ + + Create Email Broadcast + + +
+ + + + Email Content + Send Settings + + {/* Email Content Tab */} + + ( + + Email Subject + + + + + + )} + /> + + ( + + Email Content + + { + form.setValue(field.name, value || ''); + }} + /> + + + Use Markdown editor to write email content with preview functionality + + + + )} + /> + + + {/* Send Settings Tab */} + + {/* Send scope and estimated recipients */} +
+ ( + + Send Scope + + + Choose the user scope for email sending. Select “Additional emails + only” to send only to the email addresses filled below + + + )} + /> + + {/* Estimated recipients info */} +
+
+ Estimated recipients: + + {estimatedRecipients.total} + + + (users: {estimatedRecipients.users}, additional:{' '} + {estimatedRecipients.additional}) + +
+
+
+ +
+ ( + + Registration Start Date + + + + + Include users registered on or after this date + + + )} + /> + ( + + Registration End Date + + + + + Include users registered on or before this date + + + )} + /> +
+ + {/* Additional recipients */} + ( + + Additional Recipient Emails + +