From 47d19d1b445112505ca9834a0d7489f95bc4e45d Mon Sep 17 00:00:00 2001 From: web Date: Tue, 5 Aug 2025 06:42:39 -0700 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20Refactoring=20?= =?UTF-8?q?and=20adding=20multiple=20features?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/dashboard/application/config.tsx | 2 +- apps/admin/app/dashboard/application/form.tsx | 2 +- apps/admin/app/dashboard/application/page.tsx | 2 +- .../marketing/email/broadcast-form.tsx | 480 ++++++++++++++++++ .../dashboard/marketing/email/logs-table.tsx | 225 ++++++++ apps/admin/app/dashboard/marketing/page.tsx | 41 ++ .../{subscribe => product}/group/form.tsx | 2 +- .../{subscribe => product}/group/table.tsx | 2 +- apps/admin/app/dashboard/product/page.tsx | 25 + .../{subscribe => product}/subscribe-form.tsx | 2 +- .../subscribe-table.tsx | 2 +- .../app/dashboard/rules/import-yaml-rules.tsx | 240 --------- apps/admin/app/dashboard/rules/page.tsx | 244 --------- apps/admin/app/dashboard/rules/rule-form.tsx | 266 ---------- .../app/dashboard/subscribe/config-form.tsx | 250 +++++++++ apps/admin/app/dashboard/subscribe/page.tsx | 42 +- .../app/dashboard/subscribe/protocol-form.tsx | 240 +++++++++ .../dashboard/subscribe/subscribe-config.tsx | 106 ---- apps/admin/components/sidebar-left.tsx | 28 +- apps/admin/config/navs.ts | 83 +-- apps/admin/locales/cs-CZ/menu.json | 15 +- apps/admin/locales/cs-CZ/product.json | 161 ++++++ apps/admin/locales/cs-CZ/subscribe.json | 220 +++----- apps/admin/locales/de-DE/menu.json | 15 +- apps/admin/locales/de-DE/product.json | 160 ++++++ apps/admin/locales/de-DE/subscribe.json | 222 +++----- apps/admin/locales/en-US/marketing.json | 39 ++ apps/admin/locales/en-US/menu.json | 16 +- apps/admin/locales/en-US/product.json | 146 ++++++ apps/admin/locales/en-US/subscribe.json | 224 +++----- apps/admin/locales/es-ES/menu.json | 15 +- apps/admin/locales/es-ES/product.json | 161 ++++++ apps/admin/locales/es-ES/subscribe.json | 220 +++----- apps/admin/locales/es-MX/menu.json | 15 +- apps/admin/locales/es-MX/product.json | 160 ++++++ apps/admin/locales/es-MX/subscribe.json | 220 +++----- apps/admin/locales/fa-IR/menu.json | 15 +- apps/admin/locales/fa-IR/product.json | 160 ++++++ apps/admin/locales/fa-IR/subscribe.json | 224 +++----- apps/admin/locales/fi-FI/menu.json | 15 +- apps/admin/locales/fi-FI/product.json | 160 ++++++ apps/admin/locales/fi-FI/subscribe.json | 228 +++------ apps/admin/locales/fr-FR/menu.json | 15 +- apps/admin/locales/fr-FR/product.json | 160 ++++++ apps/admin/locales/fr-FR/subscribe.json | 220 +++----- apps/admin/locales/hi-IN/menu.json | 15 +- apps/admin/locales/hi-IN/product.json | 160 ++++++ apps/admin/locales/hi-IN/subscribe.json | 218 +++----- apps/admin/locales/hu-HU/menu.json | 15 +- apps/admin/locales/hu-HU/product.json | 160 ++++++ apps/admin/locales/hu-HU/subscribe.json | 224 +++----- apps/admin/locales/ja-JP/menu.json | 15 +- apps/admin/locales/ja-JP/product.json | 160 ++++++ apps/admin/locales/ja-JP/subscribe.json | 222 +++----- apps/admin/locales/ko-KR/menu.json | 15 +- apps/admin/locales/ko-KR/product.json | 160 ++++++ apps/admin/locales/ko-KR/subscribe.json | 218 +++----- apps/admin/locales/no-NO/menu.json | 15 +- apps/admin/locales/no-NO/product.json | 160 ++++++ apps/admin/locales/no-NO/subscribe.json | 222 +++----- apps/admin/locales/pl-PL/menu.json | 15 +- apps/admin/locales/pl-PL/product.json | 160 ++++++ apps/admin/locales/pl-PL/subscribe.json | 228 +++------ apps/admin/locales/pt-BR/menu.json | 15 +- apps/admin/locales/pt-BR/product.json | 160 ++++++ apps/admin/locales/pt-BR/subscribe.json | 220 +++----- apps/admin/locales/request.ts | 5 +- apps/admin/locales/ro-RO/menu.json | 15 +- apps/admin/locales/ro-RO/product.json | 160 ++++++ apps/admin/locales/ro-RO/subscribe.json | 226 +++------ apps/admin/locales/ru-RU/menu.json | 15 +- apps/admin/locales/ru-RU/product.json | 160 ++++++ apps/admin/locales/ru-RU/subscribe.json | 228 +++------ apps/admin/locales/th-TH/menu.json | 15 +- apps/admin/locales/th-TH/product.json | 160 ++++++ apps/admin/locales/th-TH/subscribe.json | 224 +++----- apps/admin/locales/tr-TR/menu.json | 15 +- apps/admin/locales/tr-TR/product.json | 160 ++++++ apps/admin/locales/tr-TR/subscribe.json | 222 +++----- apps/admin/locales/uk-UA/menu.json | 15 +- apps/admin/locales/uk-UA/product.json | 160 ++++++ apps/admin/locales/uk-UA/subscribe.json | 224 +++----- apps/admin/locales/vi-VN/menu.json | 15 +- apps/admin/locales/vi-VN/product.json | 160 ++++++ apps/admin/locales/vi-VN/subscribe.json | 236 +++------ apps/admin/locales/zh-CN/marketing.json | 39 ++ apps/admin/locales/zh-CN/menu.json | 16 +- apps/admin/locales/zh-CN/product.json | 148 ++++++ apps/admin/locales/zh-CN/subscribe.json | 218 +++----- apps/admin/locales/zh-HK/menu.json | 15 +- apps/admin/locales/zh-HK/product.json | 161 ++++++ apps/admin/locales/zh-HK/subscribe.json | 218 +++----- apps/admin/public/images/protocols/Clash.webp | Bin 0 -> 2832 bytes apps/admin/public/images/protocols/Egern.webp | Bin 0 -> 14604 bytes .../public/images/protocols/Hiddify.webp | Bin 0 -> 1380 bytes apps/admin/public/images/protocols/Loon.webp | Bin 0 -> 1766 bytes apps/admin/public/images/protocols/Netch.webp | Bin 0 -> 12618 bytes .../public/images/protocols/Quantumult.webp | Bin 0 -> 2178 bytes .../public/images/protocols/Shadowrocket.webp | Bin 0 -> 3010 bytes .../public/images/protocols/SingBox.webp | Bin 0 -> 2126 bytes apps/admin/public/images/protocols/Stash.webp | Bin 0 -> 5358 bytes .../public/images/protocols/Surfboard.webp | Bin 0 -> 1034 bytes apps/admin/public/images/protocols/Surge.webp | Bin 0 -> 1868 bytes apps/admin/public/images/protocols/V2box.webp | Bin 0 -> 1400 bytes .../admin/public/images/protocols/V2rayN.webp | Bin 0 -> 2900 bytes .../public/images/protocols/V2rayNg.webp | Bin 0 -> 1846 bytes 106 files changed, 6893 insertions(+), 4604 deletions(-) create mode 100644 apps/admin/app/dashboard/marketing/email/broadcast-form.tsx create mode 100644 apps/admin/app/dashboard/marketing/email/logs-table.tsx create mode 100644 apps/admin/app/dashboard/marketing/page.tsx rename apps/admin/app/dashboard/{subscribe => product}/group/form.tsx (98%) rename apps/admin/app/dashboard/{subscribe => product}/group/table.tsx (99%) create mode 100644 apps/admin/app/dashboard/product/page.tsx rename apps/admin/app/dashboard/{subscribe => product}/subscribe-form.tsx (99%) rename apps/admin/app/dashboard/{subscribe => product}/subscribe-table.tsx (99%) delete mode 100644 apps/admin/app/dashboard/rules/import-yaml-rules.tsx delete mode 100644 apps/admin/app/dashboard/rules/page.tsx delete mode 100644 apps/admin/app/dashboard/rules/rule-form.tsx create mode 100644 apps/admin/app/dashboard/subscribe/config-form.tsx create mode 100644 apps/admin/app/dashboard/subscribe/protocol-form.tsx delete mode 100644 apps/admin/app/dashboard/subscribe/subscribe-config.tsx create mode 100644 apps/admin/locales/cs-CZ/product.json create mode 100644 apps/admin/locales/de-DE/product.json create mode 100644 apps/admin/locales/en-US/marketing.json create mode 100644 apps/admin/locales/en-US/product.json create mode 100644 apps/admin/locales/es-ES/product.json create mode 100644 apps/admin/locales/es-MX/product.json create mode 100644 apps/admin/locales/fa-IR/product.json create mode 100644 apps/admin/locales/fi-FI/product.json create mode 100644 apps/admin/locales/fr-FR/product.json create mode 100644 apps/admin/locales/hi-IN/product.json create mode 100644 apps/admin/locales/hu-HU/product.json create mode 100644 apps/admin/locales/ja-JP/product.json create mode 100644 apps/admin/locales/ko-KR/product.json create mode 100644 apps/admin/locales/no-NO/product.json create mode 100644 apps/admin/locales/pl-PL/product.json create mode 100644 apps/admin/locales/pt-BR/product.json create mode 100644 apps/admin/locales/ro-RO/product.json create mode 100644 apps/admin/locales/ru-RU/product.json create mode 100644 apps/admin/locales/th-TH/product.json create mode 100644 apps/admin/locales/tr-TR/product.json create mode 100644 apps/admin/locales/uk-UA/product.json create mode 100644 apps/admin/locales/vi-VN/product.json create mode 100644 apps/admin/locales/zh-CN/marketing.json create mode 100644 apps/admin/locales/zh-CN/product.json create mode 100644 apps/admin/locales/zh-HK/product.json create mode 100644 apps/admin/public/images/protocols/Clash.webp create mode 100644 apps/admin/public/images/protocols/Egern.webp create mode 100644 apps/admin/public/images/protocols/Hiddify.webp create mode 100644 apps/admin/public/images/protocols/Loon.webp create mode 100644 apps/admin/public/images/protocols/Netch.webp create mode 100644 apps/admin/public/images/protocols/Quantumult.webp create mode 100644 apps/admin/public/images/protocols/Shadowrocket.webp create mode 100644 apps/admin/public/images/protocols/SingBox.webp create mode 100644 apps/admin/public/images/protocols/Stash.webp create mode 100644 apps/admin/public/images/protocols/Surfboard.webp create mode 100644 apps/admin/public/images/protocols/Surge.webp create mode 100644 apps/admin/public/images/protocols/V2box.webp create mode 100644 apps/admin/public/images/protocols/V2rayN.webp create mode 100644 apps/admin/public/images/protocols/V2rayNg.webp 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 + +