🎉 feat: initialization
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user