🎉 feat: initialization

This commit is contained in:
web
2025-11-26 19:56:16 -08:00
commit a801849fb2
553 changed files with 213088 additions and 0 deletions
@@ -0,0 +1,670 @@
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 { HTMLEditor } from "@workspace/ui/composed/editor/html";
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
import { Icon } from "@workspace/ui/composed/icon";
import {
createBatchSendEmailTask,
getPreSendEmailCount,
} from "@workspace/ui/services/admin/marketing";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { z } from "zod";
export default function EmailBroadcastForm() {
const { t } = useTranslation("marketing");
// Define schema with internationalized error messages
const emailBroadcastSchema = z.object({
subject: z
.string()
.min(
1,
`${t("subject", "Email Subject")} ${t("cannotBeEmpty", "cannot be empty")}`
),
content: z
.string()
.min(
1,
`${t("content", "Email Content")} ${t("cannotBeEmpty", "cannot be empty")}`
),
scope: z.number(),
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",
"Please enter valid email addresses, one per line"
),
}
),
scheduled: z.string().optional(),
interval: z
.number()
.min(
0.1,
t("emailIntervalMinimum", "Email interval must be at least 0.1 seconds")
)
.optional(),
limit: z
.number()
.min(1, t("dailyLimit", "Daily limit must be at least 1"))
.optional(),
});
type EmailBroadcastFormData = z.infer<typeof emailBroadcastSchema>;
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: "",
scope: 1, // ScopeAll
register_start_time: "",
register_end_time: "",
additional: "",
scheduled: "",
interval: 1,
limit: 1000,
},
});
// Calculate recipient count
const calculateRecipients = async () => {
const formData = form.getValues();
try {
// Call API to get actual recipient count
const scope = formData.scope || 1; // Default to ScopeAll
// Convert dates to timestamps if they exist
let register_start_time = 0;
let register_end_time = 0;
if (formData.register_start_time) {
register_start_time = Math.floor(
new Date(formData.register_start_time).getTime()
);
}
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,
});
}
};
// Listen to form changes
const watchedValues = form.watch();
// Use useEffect to respond to form changes, but only when sheet is open
useEffect(() => {
if (!open) return; // Only calculate when sheet is open
const debounceTimer = setTimeout(() => {
calculateRecipients();
}, 500); // Add debounce to avoid too frequent API calls
return () => clearTimeout(debounceTimer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
open, // Add open dependency
watchedValues.scope,
watchedValues.register_start_time,
watchedValues.register_end_time,
watchedValues.additional,
]);
const onSubmit = async (data: EmailBroadcastFormData) => {
setLoading(true);
try {
// Validate scheduled send 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(
t(
"scheduledSendTimeMustBeLater",
"Scheduled send time must be later than current time"
)
);
return;
}
scheduled = Math.floor(scheduledDate.getTime());
}
let register_start_time = 0;
let register_end_time = 0;
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",
"Email broadcast task created successfully"
)
);
} else {
toast.success(
t("emailAddedToScheduledQueue", "Email added to scheduled send queue")
);
}
form.reset();
setOpen(false);
} catch (error) {
console.error("Email broadcast failed:", error);
toast.error(t("sendFailed", "Send failed, please try again"));
} finally {
setLoading(false);
}
};
return (
<Sheet onOpenChange={setOpen} open={open}>
<SheetTrigger asChild>
<div className="flex cursor-pointer items-center justify-between transition-colors">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Icon className="h-5 w-5 text-primary" icon="mdi:email-send" />
</div>
<div className="flex-1">
<p className="font-medium">
{t("emailBroadcast", "Email Broadcast")}
</p>
<p className="text-muted-foreground text-sm">
{t(
"createNewEmailBroadcastCampaign",
"Create new email broadcast campaign"
)}
</p>
</div>
</div>
<Icon className="size-6" icon="mdi:chevron-right" />
</div>
</SheetTrigger>
<SheetContent className="w-[700px] max-w-full md:max-w-screen-lg">
<SheetHeader>
<SheetTitle>{t("createBroadcast", "Create Broadcast")}</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))] px-6">
<Form {...form}>
<form
className="space-y-2 pt-4"
id="broadcast-form"
onSubmit={form.handleSubmit(onSubmit)}
>
<Tabs className="space-y-2" defaultValue="content">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="content">
{t("content", "Email Content")}
</TabsTrigger>
<TabsTrigger value="settings">
{t("sendSettings", "Send Settings")}
</TabsTrigger>
</TabsList>
{/* Email Content Tab */}
<TabsContent className="space-y-2" value="content">
<FormField
control={form.control}
name="subject"
render={({ field }) => (
<FormItem>
<FormLabel>{t("subject", "Email Subject")}</FormLabel>
<FormControl>
<Input
placeholder={`${t("pleaseEnter", "Please enter")} ${t("subject", "Email Subject").toLowerCase()}`}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="content"
render={({ field }) => (
<FormItem>
<FormLabel>{t("content", "Email Content")}</FormLabel>
<FormControl>
<HTMLEditor
onChange={(value) => {
form.setValue(field.name, value || "");
}}
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"useMarkdownEditor",
"Use Markdown editor to write email content with preview functionality"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</TabsContent>
{/* Send Settings Tab */}
<TabsContent className="space-y-2" value="settings">
{/* Send scope and estimated recipients */}
<div className="grid grid-cols-2 items-center gap-4">
<FormField
control={form.control}
name="scope"
render={({ field }) => (
<FormItem>
<FormLabel>{t("sendScope", "Send Scope")}</FormLabel>
<Select
onValueChange={(value) =>
field.onChange(Number.parseInt(value, 10))
}
value={field.value?.toString() || "1"}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={t(
"selectSendScope",
"Select send scope"
)}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="1">
{t("allUsers", "All Users")}
</SelectItem>{" "}
{/* ScopeAll */}
<SelectItem value="2">
{t(
"subscribedUsersOnly",
"Subscribed users only"
)}
</SelectItem>{" "}
{/* ScopeActive */}
<SelectItem value="3">
{t(
"expiredSubscriptionUsersOnly",
"Expired subscription users only"
)}
</SelectItem>{" "}
{/* ScopeExpired */}
<SelectItem value="4">
{t(
"noSubscriptionUsersOnly",
"No subscription users only"
)}
</SelectItem>{" "}
{/* ScopeNone */}
<SelectItem value="5">
{t(
"specificUsersOnly",
"Additional emails only (skip platform users)"
)}
</SelectItem>{" "}
{/* ScopeSkip */}
</SelectContent>
</Select>
<FormDescription>
{t(
"sendScopeDescription",
'Choose the user scope for email sending. Select "Additional emails only" to send only to the email addresses filled below'
)}
</FormDescription>
</FormItem>
)}
/>
{/* Estimated recipients info */}
<div className="flex justify-end">
<div className="border-l-4 border-l-primary bg-primary/10 px-4 py-3 text-sm">
<span className="text-muted-foreground">
{t("estimatedRecipients", "Estimated recipients")}:{" "}
</span>
<span className="font-medium text-lg text-primary">
{estimatedRecipients.total}
</span>
<span className="ml-2 text-muted-foreground text-xs">
({t("users", "users")}: {estimatedRecipients.users},{" "}
{t("additional", "Additional")}:{" "}
{estimatedRecipients.additional})
</span>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="register_start_time"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"registrationStartDate",
"Registration Start Date"
)}
</FormLabel>
<FormControl>
<EnhancedInput
disabled={form.watch("scope") === 5}
onValueChange={field.onChange}
step="1" // ScopeSkip
type="datetime-local"
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"includeUsersRegisteredAfter",
"Include users registered on or after this date"
)}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="register_end_time"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("registrationEndDate", "Registration End Date")}
</FormLabel>
<FormControl>
<EnhancedInput
disabled={form.watch("scope") === 5}
onValueChange={field.onChange}
step="1" // ScopeSkip
type="datetime-local"
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"includeUsersRegisteredBefore",
"Include users registered on or before this date"
)}
</FormDescription>
</FormItem>
)}
/>
</div>
{/* Additional recipients */}
<FormField
control={form.control}
name="additional"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"additionalRecipientEmails",
"Additional recipient emails"
)}
</FormLabel>
<FormControl>
<Textarea
className="min-h-[120px] font-mono text-sm"
placeholder={`${t("pleaseEnter", "Please enter")}${t("additionalRecipientEmails", "Additional recipient emails").toLowerCase()}${t("onePerLine", "one per line")}for example:\nexample1@domain.com\nexample2@domain.com\nexample3@domain.com`}
{...field}
/>
</FormControl>
<FormDescription>
{t(
"additionalRecipientsDescription",
"These emails will receive the broadcast in addition to the user filter above"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Send time settings */}
<FormField
control={form.control}
name="scheduled"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("scheduledSend", "Schedule Send")}
</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={field.onChange}
placeholder={t(
"leaveEmptyForImmediateSend",
"Leave empty for immediate send"
)}
step="1"
type="datetime-local"
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"selectSendTime",
"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="interval"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("emailInterval", "Email Interval (seconds)")}
</FormLabel>
<FormControl>
<Input
min={1}
placeholder="1"
step={0.1}
type="number"
{...field}
onChange={(e) =>
field.onChange(
Number.parseFloat(e.target.value) || 1
)
}
/>
</FormControl>
<FormDescription>
{t(
"intervalTimeBetweenEmails",
"Interval time between each email"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="limit"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("dailySendLimit", "Daily Send Limit")}
</FormLabel>
<FormControl>
<Input
min={1}
placeholder="1000"
step={1}
type="number"
{...field}
onChange={(e) =>
field.onChange(
Number.parseInt(e.target.value, 10) || 1000
)
}
/>
</FormControl>
<FormDescription>
{t(
"maximumNumberPerDay",
"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 onClick={() => setOpen(false)} variant="outline">
{t("cancel", "Cancel")}
</Button>
<Button disabled={loading} form="broadcast-form" type="submit">
{loading && (
<Icon className="mr-2 h-4 w-4 animate-spin" icon="mdi:loading" />
)}
{loading
? t("processing", "Processing...")
: !form.watch("scheduled") ||
form.watch("scheduled")?.trim() === ""
? t("sendNow", "Send Now")
: t("scheduleSend", "Schedule Send")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,308 @@
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/composed/icon";
import {
ProTable,
type ProTableActions,
} from "@workspace/ui/composed/pro-table/pro-table";
import {
getBatchSendEmailTaskList,
stopBatchSendEmailTask,
} from "@workspace/ui/services/admin/marketing";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { formatDate } from "@/utils/common";
export default function EmailTaskManager() {
const { t } = useTranslation("marketing");
const ref = useRef<ProTableActions>(null);
const [selectedTask, setSelectedTask] =
useState<API.BatchSendEmailTask | null>(null);
const [open, setOpen] = useState(false);
const stopTask = async (taskId: number) => {
try {
await stopBatchSendEmailTask({
id: taskId,
});
toast.success(t("taskStoppedSuccessfully", "Task stopped successfully"));
ref.current?.refresh();
} catch (error) {
console.error("Failed to stop task:", error);
toast.error(t("failedToStopTask", "Failed to stop task"));
}
};
const getStatusBadge = (status: number) => {
const statusConfig = {
0: {
label: t("notStarted", "Not Started"),
variant: "secondary" as const,
},
1: { label: t("inProgress", "In Progress"), variant: "default" as const },
2: { label: t("completed", "Completed"), variant: "default" as const },
};
const config = statusConfig[status as keyof typeof statusConfig] || {
label: `${t("status", "Status")} ${status}`,
variant: "secondary" as const,
};
return <Badge variant={config.variant}>{config.label}</Badge>;
};
return (
<Sheet onOpenChange={setOpen} open={open}>
<SheetTrigger asChild>
<div className="flex cursor-pointer items-center justify-between transition-colors">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Icon
className="h-5 w-5 text-primary"
icon="mdi:email-multiple"
/>
</div>
<div className="flex-1">
<p className="font-medium">
{t("emailTaskManager", "Email Task Manager")}
</p>
<p className="text-muted-foreground text-sm">
{t(
"viewAndManageEmailBroadcastTasks",
"View and manage email broadcast tasks"
)}
</p>
</div>
</div>
<Icon className="size-6" icon="mdi:chevron-right" />
</div>
</SheetTrigger>
<SheetContent className="w-[1000px] max-w-full md:max-w-screen-lg">
<SheetHeader>
<SheetTitle>
{t("emailBroadcastTasks", "Email Broadcast Tasks")}
</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100dvh-48px-36px-env(safe-area-inset-top))] px-6">
<div className="mt-4 space-y-4">
<ProTable<
API.BatchSendEmailTask,
API.GetBatchSendEmailTaskListParams
>
action={ref}
actions={{
render: (row) => [
<Dialog key="view-content">
<DialogTrigger asChild>
<Button
onClick={() =>
setSelectedTask(row as API.BatchSendEmailTask)
}
size="icon"
variant="outline"
>
<Icon icon="mdi:eye" />
</Button>
</DialogTrigger>
<DialogContent className="max-h-[80vh] max-w-4xl">
<DialogHeader>
<DialogTitle>
{t("emailContent", "Email Content")}
</DialogTitle>
</DialogHeader>
<ScrollArea className="h-[60vh] pr-4">
{selectedTask && (
<div className="space-y-4">
<div>
<h4 className="mb-2 font-medium text-muted-foreground text-sm">
{t("subject", "Email Subject")}
</h4>
<p className="font-medium">
{selectedTask.subject}
</p>
</div>
<div>
<h4 className="mb-2 font-medium text-muted-foreground text-sm">
{t("content", "Email Content")}
</h4>
<div
dangerouslySetInnerHTML={{
__html: selectedTask.content,
}}
/>
</div>
{selectedTask.additional && (
<div>
<h4 className="mb-2 font-medium text-muted-foreground text-sm">
{t(
"additionalRecipients",
"Additional Recipients"
)}
</h4>
<p className="text-sm">
{selectedTask.additional}
</p>
</div>
)}
</div>
)}
</ScrollArea>
</DialogContent>
</Dialog>,
...([0, 1].includes(row.status)
? [
<Button
key="stop"
onClick={() => stopTask(row.id)}
variant="destructive"
>
{t("stop", "Stop")}
</Button>,
]
: []),
],
}}
columns={[
{
accessorKey: "subject",
header: t("subject", "Email Subject"),
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", "Recipient Type"),
cell: ({ row }) => {
const scope = row.original.scope;
const scopeLabels = {
1: t("allUsers", "All Users"), // ScopeAll
2: t("subscribedUsers", "Subscribed Users"), // ScopeActive
3: t("expiredUsers", "Expired Users"), // ScopeExpired
4: t("nonSubscribers", "Non-subscribers"), // ScopeNone
5: t("specificUsers", "Specific Users"), // ScopeSkip
};
return (
scopeLabels[scope as keyof typeof scopeLabels] ||
`${t("scope", "Send Scope")} ${scope}`
);
},
},
{
accessorKey: "status",
header: t("status", "Status"),
cell: ({ row }) =>
getStatusBadge(row.getValue("status") as number),
},
{
accessorKey: "progress",
header: t("progress", "Progress"),
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="h-2 overflow-hidden rounded-full bg-muted">
<div
className="h-full bg-primary transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
</div>
);
},
},
{
accessorKey: "scheduled",
header: t("sendTime", "Send Time"),
cell: ({ row }) => {
const scheduled = row.getValue("scheduled") as number;
return scheduled && scheduled > 0
? formatDate(scheduled)
: "--";
},
},
{
accessorKey: "created_at",
header: t("createdAt", "Created At"),
cell: ({ row }) => {
const createdAt = row.getValue("created_at") as number;
return formatDate(createdAt);
},
},
]}
params={[
{
key: "status",
placeholder: t("status", "Status"),
options: [
{ label: t("notStarted", "Not Started"), value: "0" },
{ label: t("inProgress", "In Progress"), value: "1" },
{ label: t("completed", "Completed"), value: "2" },
],
},
{
key: "scope",
placeholder: t("sendScope", "Send Scope"),
options: [
{ label: t("allUsers", "All Users"), value: "1" },
{
label: t("subscribedUsers", "Subscribed Users"),
value: "2",
},
{ label: t("expiredUsers", "Expired Users"), value: "3" },
{
label: t("nonSubscribers", "Non-subscribers"),
value: "4",
},
{ label: t("specificUsers", "Specific Users"), value: "5" },
],
},
]}
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,
};
}}
/>
</div>
</ScrollArea>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,56 @@
import {
Table,
TableBody,
TableCell,
TableRow,
} from "@workspace/ui/components/table";
import { useTranslation } from "react-i18next";
import EmailBroadcastForm from "./email/broadcast-form";
import EmailTaskManager from "./email/task-manager";
import QuotaBroadcastForm from "./quota/broadcast-form";
import QuotaTaskManager from "./quota/task-manager";
export default function MarketingPage() {
const { t } = useTranslation("marketing");
const formSections = [
{
title: t("emailMarketing", "Email Marketing"),
forms: [
{ component: EmailBroadcastForm },
{ component: EmailTaskManager },
],
},
{
title: t("quotaService", "Quota Service"),
forms: [
{ component: QuotaBroadcastForm },
{ component: QuotaTaskManager },
],
},
];
return (
<div className="space-y-8">
{formSections.map((section, sectionIndex) => (
<div key={sectionIndex}>
<h2 className="mb-4 font-semibold text-lg">{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>
);
}
@@ -0,0 +1,534 @@
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 {
RadioGroup,
RadioGroupItem,
} from "@workspace/ui/components/radio-group";
import { ScrollArea } from "@workspace/ui/components/scroll-area";
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@workspace/ui/components/sheet";
import { Switch } from "@workspace/ui/components/switch";
import { Combobox } from "@workspace/ui/composed/combobox";
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
import { Icon } from "@workspace/ui/composed/icon";
import {
createQuotaTask,
queryQuotaTaskPreCount,
} from "@workspace/ui/services/admin/marketing";
import { unitConversion } from "@workspace/ui/utils/unit-conversions";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { z } from "zod";
import { Display } from "@/components/display";
import { useSubscribe } from "@/stores/subscribe";
export default function QuotaBroadcastForm() {
const { t } = useTranslation("marketing");
// Define schema with internationalized error messages
const quotaBroadcastSchema = z.object({
subscribers: z
.array(z.number())
.min(1, t("pleaseSelectSubscribers", "Please select packages")),
is_active: z.boolean(),
start_time: z.string().optional(),
end_time: z.string().optional(),
reset_traffic: z.boolean(),
days: z.number().optional(),
gift_type: z.number(),
gift_value: z.number().optional(),
});
type QuotaBroadcastFormData = z.infer<typeof quotaBroadcastSchema>;
const form = useForm<QuotaBroadcastFormData>({
resolver: zodResolver(quotaBroadcastSchema),
mode: "onChange", // Enable real-time validation
defaultValues: {
subscribers: [],
is_active: true,
start_time: "",
end_time: "",
reset_traffic: false,
days: 0,
gift_type: 1,
gift_value: 0,
},
});
const [recipients, setRecipients] = useState<number>(0);
const [isCalculating, setIsCalculating] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [open, setOpen] = useState(false);
const { subscribes } = useSubscribe();
// Calculate recipient count
const calculateRecipients = async () => {
setIsCalculating(true);
try {
const formData = form.getValues();
let start_time = 0;
let end_time = 0;
if (formData.start_time) {
start_time = new Date(formData.start_time).getTime();
}
if (formData.end_time) {
end_time = new Date(formData.end_time).getTime();
}
const response = await queryQuotaTaskPreCount({
subscribers: formData.subscribers,
is_active: formData.is_active,
start_time,
end_time,
});
if (response.data?.data?.count !== undefined) {
setRecipients(response.data.data.count);
}
} catch (error) {
console.error("Failed to calculate recipients:", error);
toast.error(
t("failedToCalculateRecipients", "Failed to calculate recipients")
);
setRecipients(0);
} finally {
setIsCalculating(false);
}
};
// Watch form values and recalculate recipients only when sheet is open
const watchedValues = form.watch();
useEffect(() => {
if (!open) return; // Only calculate when sheet is open
const debounceTimer = setTimeout(() => {
calculateRecipients();
}, 500); // Add debounce to avoid too frequent API calls
return () => clearTimeout(debounceTimer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
open,
watchedValues.subscribers,
watchedValues.is_active,
watchedValues.start_time,
watchedValues.end_time,
]);
const onSubmit = async (data: QuotaBroadcastFormData) => {
setIsSubmitting(true);
try {
let start_time = 0;
let end_time = 0;
if (data.start_time) {
start_time = Math.floor(new Date(data.start_time).getTime());
}
if (data.end_time) {
end_time = Math.floor(new Date(data.end_time).getTime());
}
await createQuotaTask({
subscribers: data.subscribers,
is_active: data.is_active,
start_time,
end_time,
reset_traffic: data.reset_traffic,
days: data.days || 0,
gift_type: data.gift_type,
gift_value: data.gift_value || 0,
});
toast.success(
t("quotaTaskCreatedSuccessfully", "Quota task created successfully")
);
form.reset();
setRecipients(0);
setOpen(false); // Close the sheet after successful submission
} catch (error) {
console.error("Failed to create quota task:", error);
toast.error(t("failedToCreateQuotaTask", "Failed to create quota task"));
} finally {
setIsSubmitting(false);
}
};
return (
<Sheet onOpenChange={setOpen} open={open}>
<SheetTrigger asChild>
<div className="flex cursor-pointer items-center justify-between transition-colors">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Icon className="h-5 w-5 text-primary" icon="mdi:gift" />
</div>
<div className="flex-1">
<p className="font-medium">
{t("quotaBroadcast", "Quota Distribution")}
</p>
<p className="text-muted-foreground text-sm">
{t(
"createAndSendQuotaTasks",
"Create and Distribute Quota Tasks"
)}
</p>
</div>
</div>
<Icon className="size-6" icon="mdi:chevron-right" />
</div>
</SheetTrigger>
<SheetContent className="w-[600px] max-w-full md:max-w-screen-md">
<SheetHeader>
<SheetTitle>{t("createQuotaTask", "Create Quota Task")}</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100dvh-48px-36px-32px-env(safe-area-inset-top))] px-6">
<Form {...form}>
<form
className="mt-4 space-y-6"
id="quota-broadcast-form"
onSubmit={form.handleSubmit(onSubmit)}
>
{/* Subscribers selection */}
<FormField
control={form.control}
name="subscribers"
render={({ field }) => (
<FormItem>
<FormLabel>{t("subscribers", "Packages")}</FormLabel>
<FormControl>
<Combobox
multiple={true}
onChange={field.onChange}
options={subscribes?.map((subscribe) => ({
value: subscribe.id!,
label: subscribe.name!,
children: (
<div>
<div>{subscribe.name}</div>
<div className="text-muted-foreground text-xs">
<Display
type="traffic"
value={subscribe.traffic || 0}
/>{" "}
/{" "}
<Display
type="currency"
value={subscribe.unit_price || 0}
/>
</div>
</div>
),
}))}
placeholder={t(
"pleaseSelectSubscribers",
"Please select packages"
)}
value={field.value || []}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Subscription count info and active status */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="is_active"
render={({ field }) => (
<FormItem>
<FormLabel>{t("validOnly", "Valid Only")}</FormLabel>
<FormControl>
<Switch
checked={field.value}
className="!mt-0 float-end"
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t(
"selectValidSubscriptionsOnly",
"Select currently valid subscriptions only"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex items-center border-l-4 border-l-primary bg-primary/10 px-4 py-3 text-sm">
<span className="text-muted-foreground">
{t("subscriptionCount", "Subscription Count")}:{" "}
</span>
<span className="font-medium text-lg text-primary">
{isCalculating ? (
<Icon
className="ml-2 h-4 w-4 animate-spin"
icon="mdi:loading"
/>
) : (
recipients.toLocaleString()
)}
</span>
</div>
</div>
{/* Subscription validity period range */}
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="start_time"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"subscriptionValidityStartDate",
"Subscription Validity Start Date"
)}
</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={field.onChange}
step="1"
type="datetime-local"
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"includeSubscriptionsValidAfter",
"Include subscriptions valid on or after this date"
)}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="end_time"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"subscriptionValidityEndDate",
"Subscription Validity End Date"
)}
</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={field.onChange}
step="1"
type="datetime-local"
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"includeSubscriptionsValidBefore",
"Include subscriptions valid on or before this date"
)}
</FormDescription>
</FormItem>
)}
/>
</div>
{/* Reset traffic */}
<FormField
control={form.control}
name="reset_traffic"
render={({ field }) => (
<FormItem>
<FormLabel>{t("resetTraffic", "Reset Traffic")}</FormLabel>
<FormControl>
<Switch
checked={field.value}
className="!mt-0 float-end"
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t(
"resetTrafficDescription",
"Whether to reset subscription used traffic"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Quota days */}
<FormField
control={form.control}
name="days"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("quotaDays", "Extend Expiration Days")}
</FormLabel>
<FormControl>
<EnhancedInput
min={1}
onValueChange={(value) =>
field.onChange(Number.parseInt(value, 10))
}
type="number"
value={field.value?.toString()}
/>
</FormControl>
<FormDescription>
{t(
"numberOfDaysForTheQuota",
"Number of days to extend subscription expiration"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Gift configuration */}
<FormField
control={form.control}
name="gift_type"
render={({ field }) => (
<FormItem>
<FormLabel>{t("giftType", "Gift Amount Type")}</FormLabel>
<FormControl>
<RadioGroup
className="flex gap-4"
defaultValue={String(field.value)}
onValueChange={(value) => {
field.onChange(Number(value));
form.setValue("gift_value", 0);
}}
>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="1" />
</FormControl>
<FormLabel className="font-normal">
{t("fixedAmount", "Fixed Amount")}
</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="2" />
</FormControl>
<FormLabel className="font-normal">
{t("percentageAmount", "Percentage Amount")}
</FormLabel>
</FormItem>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Gift amount based on type */}
{form.watch("gift_type") === 1 && (
<FormField
control={form.control}
name="gift_value"
render={({ field }) => (
<FormItem>
<FormLabel>{t("giftAmount", "Gift Amount")}</FormLabel>
<FormControl>
<EnhancedInput<number>
formatInput={(value) =>
unitConversion("centsToDollars", value)
}
formatOutput={(value) =>
unitConversion("dollarsToCents", value)
}
min={1}
onValueChange={(value) => field.onChange(value)}
placeholder={t("enterAmount", "Enter amount")}
type="number"
value={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{form.watch("gift_type") === 2 && (
<FormField
control={form.control}
name="gift_value"
render={({ field }) => (
<FormItem>
<FormLabel>{t("giftAmount", "Gift Amount")}</FormLabel>
<FormControl>
<EnhancedInput
max={100}
min={1}
onValueChange={(value) => field.onChange(value)}
placeholder={t("enterPercentage", "Enter percentage")}
suffix="%"
type="number"
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"percentageAmountDescription",
"Gift percentage amount based on current package price"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</form>
</Form>
</ScrollArea>
<SheetFooter className="flex flex-row items-center justify-end gap-2 pt-3">
<Button onClick={() => setOpen(false)} variant="outline">
{t("cancel", "Cancel")}
</Button>
<Button
disabled={
isSubmitting ||
!form.formState.isValid ||
form.watch("subscribers").length === 0
}
form="quota-broadcast-form"
type="submit"
>
{isSubmitting && (
<Icon className="mr-2 h-4 w-4 animate-spin" icon="mdi:loading" />
)}
{t("createQuotaTask", "Create Quota Task")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,256 @@
import { Badge } from "@workspace/ui/components/badge";
import { ScrollArea } from "@workspace/ui/components/scroll-area";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@workspace/ui/components/sheet";
import { Icon } from "@workspace/ui/composed/icon";
import { ProTable } from "@workspace/ui/composed/pro-table/pro-table";
import { queryQuotaTaskList } from "@workspace/ui/services/admin/marketing";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Display } from "@/components/display";
import { useSubscribe } from "@/stores/subscribe";
import { formatDate } from "@/utils/common";
export default function QuotaTaskManager() {
const { t } = useTranslation("marketing");
const [open, setOpen] = useState(false);
const { subscribes } = useSubscribe();
const subscribeMap =
subscribes?.reduce(
(acc, subscribe) => {
acc[subscribe.id!] = subscribe.name!;
return acc;
},
{} as Record<number, string>
) || {};
const getStatusBadge = (status: number) => {
const statusConfig = {
0: {
label: t("notStarted", "Not Started"),
variant: "secondary" as const,
},
1: { label: t("inProgress", "In Progress"), variant: "default" as const },
2: { label: t("completed", "Completed"), variant: "default" as const },
};
const config = statusConfig[status as keyof typeof statusConfig] || {
label: `${t("status", "Status")} ${status}`,
variant: "secondary" as const,
};
return <Badge variant={config.variant}>{config.label}</Badge>;
};
return (
<Sheet onOpenChange={setOpen} open={open}>
<SheetTrigger asChild>
<div className="flex cursor-pointer items-center justify-between transition-colors">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Icon className="h-5 w-5 text-primary" icon="mdi:database-plus" />
</div>
<div className="flex-1">
<p className="font-medium">
{t("quotaTaskManager", "Quota Task Manager")}
</p>
<p className="text-muted-foreground text-sm">
{t("viewAndManageQuotaTasks", "View and manage quota tasks")}
</p>
</div>
</div>
<Icon className="size-6" icon="mdi:chevron-right" />
</div>
</SheetTrigger>
<SheetContent className="w-[1000px] max-w-full md:max-w-screen-lg">
<SheetHeader>
<SheetTitle>{t("quotaTasks", "Quota Tasks")}</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100dvh-48px-36px-env(safe-area-inset-top))] px-6">
<div className="mt-4 space-y-4">
{open && (
<ProTable<API.QuotaTask, API.QueryQuotaTaskListParams>
columns={[
{
accessorKey: "subscribers",
header: t("subscribers", "Packages"),
size: 200,
cell: ({ row }) => {
const subscribers = row.getValue(
"subscribers"
) as number[];
const subscriptionNames =
subscribers
?.map((id) => subscribeMap[id])
.filter(Boolean) || [];
if (subscriptionNames.length === 0) {
return (
<span className="text-muted-foreground text-sm">
{t("noSubscriptions", "No Subscriptions")}
</span>
);
}
return (
<div className="flex flex-wrap gap-1">
{subscriptionNames.map((name, index) => (
<span
className="rounded bg-muted px-2 py-1 text-xs"
key={index}
>
{name}
</span>
))}
</div>
);
},
},
{
accessorKey: "is_active",
header: t("validOnly", "Valid Only"),
size: 120,
cell: ({ row }) => {
const isActive = row.getValue("is_active") as boolean;
return (
<span className="text-sm">
{isActive ? t("yes", "Yes") : t("no", "No")}
</span>
);
},
},
{
accessorKey: "reset_traffic",
header: t("resetTraffic", "Reset Traffic"),
size: 120,
cell: ({ row }) => {
const resetTraffic = row.getValue(
"reset_traffic"
) as boolean;
return (
<span className="text-sm">
{resetTraffic ? t("yes", "Yes") : t("no", "No")}
</span>
);
},
},
{
accessorKey: "gift_value",
header: t("giftAmount", "Gift Amount"),
size: 120,
cell: ({ row }) => {
const giftValue = row.getValue("gift_value") as number;
const task = row.original as API.QuotaTask;
const giftType = task.gift_type;
return (
<div className="font-medium text-sm">
{giftType === 1 ? (
<Display type="currency" value={giftValue} />
) : (
`${giftValue}%`
)}
</div>
);
},
},
{
accessorKey: "days",
header: t("quotaDays", "Extend Expiration Days"),
size: 100,
cell: ({ row }) => {
const days = row.getValue("days") as number;
return (
<span className="font-medium">
{days} {t("days", "Days")}
</span>
);
},
},
{
accessorKey: "time_range",
header: t("timeRange", "Time Range"),
size: 180,
cell: ({ row }) => {
const task = row.original as API.QuotaTask;
const startTime = task.start_time;
const endTime = task.end_time;
if (!(startTime || endTime)) {
return (
<span className="text-muted-foreground text-sm">
{t("noTimeLimit", "No Time Limit")}
</span>
);
}
return (
<div className="space-y-1 text-xs">
{startTime && (
<div>
{t("startTime", "Start Time")}:{" "}
{formatDate(startTime)}
</div>
)}
{endTime && (
<div>
{t("endTime", "End Time")}: {formatDate(endTime)}
</div>
)}
</div>
);
},
},
{
accessorKey: "status",
header: t("status", "Status"),
size: 100,
cell: ({ row }) =>
getStatusBadge(row.getValue("status") as number),
},
{
accessorKey: "created_at",
header: t("createdAt", "Created At"),
size: 150,
cell: ({ row }) => {
const createdAt = row.getValue("created_at") as number;
return formatDate(createdAt);
},
},
]}
params={[
{
key: "status",
placeholder: t("status", "Status"),
options: [
{ label: t("notStarted", "Not Started"), value: "0" },
{ label: t("inProgress", "In Progress"), value: "1" },
{ label: t("completed", "Completed"), value: "2" },
],
},
]}
request={async (pagination, filters) => {
const response = await queryQuotaTaskList({
...filters,
page: pagination.page,
size: pagination.size,
});
return {
list: response.data?.data?.list || [],
total: response.data?.data?.total || 0,
};
}}
/>
)}
</div>
</ScrollArea>
</SheetContent>
</Sheet>
);
}