🎉 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
+328
View File
@@ -0,0 +1,328 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@workspace/ui/components/button";
import {
Form,
FormControl,
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 { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
import { Icon } from "@workspace/ui/composed/icon";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { z } from "zod";
const formSchema = z.object({
title: z.string(),
type: z.enum(["image", "video"]),
content: z.string(),
description: z.string(),
target_url: z.string().url(),
start_time: z.number(),
end_time: z.number(),
});
interface AdsFormProps<T> {
onSubmit: (data: T) => Promise<boolean> | boolean;
initialValues?: T;
loading?: boolean;
trigger: string;
title: string;
}
export default function AdsForm<T extends Record<string, any>>({
onSubmit,
initialValues,
loading,
trigger,
title,
}: AdsFormProps<T>) {
const { t } = useTranslation("ads");
const [open, setOpen] = useState(false);
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: {
...initialValues,
} as any,
});
useEffect(() => {
form?.reset(initialValues);
}, [form, initialValues]);
const type = form.watch("type");
const startTime = form.watch("start_time");
const renderContentField = () => (
<FormField
control={form.control}
name="content"
render={({ field }) => (
<FormItem>
<FormLabel>{t("form.content", "Content")}</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={(value) => {
form.setValue("content", value);
}}
placeholder={
type === "image"
? "https://example.com/image.jpg"
: "https://example.com/video.mp4"
}
value={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
async function handleSubmit(data: { [x: string]: any }) {
const bool = await onSubmit(data as T);
if (bool) setOpen(false);
}
return (
<Sheet onOpenChange={setOpen} open={open}>
<SheetTrigger asChild>
<Button
onClick={() => {
form.reset();
setOpen(true);
}}
>
{trigger}
</Button>
</SheetTrigger>
<SheetContent className="w-[500px] max-w-full md:max-w-screen-md">
<SheetHeader>
<SheetTitle>{title}</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100vh-48px-36px-36px-env(safe-area-inset-top))]">
<Form {...form}>
<form
className="space-y-4 px-6 pt-4"
onSubmit={form.handleSubmit(handleSubmit)}
>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>{t("form.title", "Title")}</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={(value) => {
form.setValue(field.name, value);
}}
placeholder={t("form.enterTitle", "Enter title")}
value={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>{t("form.type", "Type")}</FormLabel>
<FormControl>
<RadioGroup
className="flex gap-4"
defaultValue={field.value}
onValueChange={(value) => {
form.setValue(field.name, value);
}}
>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="image" />
</FormControl>
<FormLabel className="font-normal">
{t("form.typeImage", "Image")}
</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="video" />
</FormControl>
<FormLabel className="font-normal">
{t("form.typeVideo", "Video")}
</FormLabel>
</FormItem>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{renderContentField()}
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("form.description", "Description")}
</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={(value) => {
form.setValue(field.name, value);
}}
placeholder={t(
"form.enterDescription",
"Enter description"
)}
value={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="target_url"
render={({ field }) => (
<FormItem>
<FormLabel>{t("form.targetUrl", "Target URL")}</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={(value) => {
form.setValue(field.name, value);
}}
placeholder={t(
"form.enterTargetUrl",
"Enter target URL"
)}
value={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="start_time"
render={({ field }) => (
<FormItem>
<FormLabel>{t("form.startTime", "Start Time")}</FormLabel>
<FormControl>
<EnhancedInput
min={Number(new Date().toISOString().slice(0, 16))}
onValueChange={(value) => {
const timestamp = value
? new Date(value).getTime()
: 0;
form.setValue(field.name, timestamp);
const endTime = form.getValues("end_time");
if (endTime && timestamp > endTime) {
form.setValue("end_time", "");
}
}}
placeholder={t(
"form.enterStartTime",
"Select start time"
)}
step="1"
type="datetime-local"
value={
field.value
? new Date(field.value).toISOString().slice(0, 16)
: ""
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="end_time"
render={({ field }) => (
<FormItem>
<FormLabel>{t("form.endTime", "End Time")}</FormLabel>
<FormControl>
<EnhancedInput
disabled={!startTime}
min={Number(
startTime
? new Date(startTime).toISOString().slice(0, 16)
: new Date().toISOString().slice(0, 16)
)}
onValueChange={(value) => {
const timestamp = value
? new Date(value).getTime()
: 0;
if (!startTime || timestamp < startTime) return;
form.setValue(field.name, timestamp);
}}
placeholder={t("form.enterEndTime", "Select end time")}
step="1"
type="datetime-local"
value={
field.value
? new Date(field.value).toISOString().slice(0, 16)
: ""
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<SheetFooter className="flex-row justify-end gap-2 pt-3">
<Button
disabled={loading}
onClick={() => {
setOpen(false);
}}
variant="outline"
>
{t("form.cancel", "Cancel")}
</Button>
<Button disabled={loading} onClick={form.handleSubmit(handleSubmit)}>
{loading && (
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
)}
{t("form.confirm", "Confirm")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
+171
View File
@@ -0,0 +1,171 @@
import { Badge } from "@workspace/ui/components/badge";
import { Button } from "@workspace/ui/components/button";
import { Switch } from "@workspace/ui/components/switch";
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
import {
ProTable,
type ProTableActions,
} from "@workspace/ui/composed/pro-table/pro-table";
import {
createAds,
deleteAds,
getAdsList,
updateAds,
} from "@workspace/ui/services/admin/ads";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { formatDate } from "@/utils/common";
import AdsForm from "./ads-form";
export default function Ads() {
const { t } = useTranslation("ads");
const [loading, setLoading] = useState(false);
const ref = useRef<ProTableActions>(null);
return (
<ProTable<API.Ads, Record<string, unknown>>
action={ref}
actions={{
render: (row) => [
<AdsForm<API.UpdateAdsRequest>
initialValues={row}
key="edit"
loading={loading}
onSubmit={async (values) => {
setLoading(true);
try {
await updateAds({ ...row, ...values });
toast.success(t("updateSuccess", "Updated successfully"));
ref.current?.refresh();
return true;
} catch {
return false;
} finally {
setLoading(false);
}
}}
title={t("editAds", "Edit Ad")}
trigger={t("edit", "Edit")}
/>,
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description={t(
"deleteWarning",
"Are you sure you want to delete this ad? This action cannot be undone."
)}
key="delete"
onConfirm={async () => {
await deleteAds({ id: row.id });
toast.success(t("deleteSuccess", "Deleted successfully"));
ref.current?.refresh();
}}
title={t("confirmDelete", "Confirm Delete")}
trigger={
<Button variant="destructive">{t("delete", "Delete")}</Button>
}
/>,
],
}}
columns={[
{
accessorKey: "status",
header: t("status", "Status"),
cell: ({ row }) => (
<Switch
defaultChecked={row.getValue("status") === 1}
onCheckedChange={async (checked) => {
await updateAds({
...row.original,
status: checked ? 1 : 0,
});
ref.current?.refresh();
}}
/>
),
},
{
accessorKey: "title",
header: t("title", "Title"),
},
{
accessorKey: "type",
header: t("type", "Type"),
cell: ({ row }) => {
const type = row.original.type;
return <Badge>{type}</Badge>;
},
},
{
accessorKey: "target_url",
header: t("targetUrl", "Target URL"),
},
{
accessorKey: "description",
header: t("form.description", "Description"),
},
{
accessorKey: "period",
header: t("validityPeriod", "Validity Period"),
cell: ({ row }) => {
const { start_time, end_time } = row.original;
return (
<>
{formatDate(start_time)} - {formatDate(end_time)}
</>
);
},
},
]}
header={{
toolbar: (
<AdsForm<API.CreateAdsRequest>
loading={loading}
onSubmit={async (values) => {
setLoading(true);
try {
await createAds({
...values,
status: 0,
});
toast.success(t("createSuccess", "Created successfully"));
ref.current?.refresh();
return true;
} catch {
return false;
} finally {
setLoading(false);
}
}}
title={t("createAds", "Create Ad")}
trigger={t("create", "Create")}
/>
),
}}
params={[
{
key: "status",
placeholder: t("status", "Status"),
options: [
{ label: t("enabled", "Enabled"), value: "1" },
{ label: t("disabled", "Disabled"), value: "0" },
],
},
{
key: "search",
},
]}
request={async (pagination, filters) => {
const { data } = await getAdsList({
...pagination,
...filters,
});
return {
list: data.data?.list || [],
total: data.data?.total || 0,
};
}}
/>
);
}
@@ -0,0 +1,213 @@
import { Button } from "@workspace/ui/components/button";
import { Switch } from "@workspace/ui/components/switch";
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
import {
ProTable,
type ProTableActions,
} from "@workspace/ui/composed/pro-table/pro-table";
import {
createAnnouncement,
deleteAnnouncement,
getAnnouncementList,
updateAnnouncement,
} from "@workspace/ui/services/admin/announcement";
import { format } from "date-fns";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import NoticeForm from "./notice-form";
export default function Page() {
const { t } = useTranslation("announcement");
const [loading, setLoading] = useState(false);
const ref = useRef<ProTableActions>(null);
return (
<ProTable<API.Announcement, { enable: boolean; search: string }>
action={ref}
actions={{
render(row) {
return [
<NoticeForm<API.Announcement>
initialValues={row}
key="edit"
loading={loading}
onSubmit={async (values) => {
setLoading(true);
try {
await updateAnnouncement({
...row,
...values,
});
toast.success(t("updateSuccess", "Updated successfully"));
ref.current?.refresh();
setLoading(false);
return true;
} catch {
setLoading(false);
return false;
}
}}
title={t("editAnnouncement", "Edit Announcement")}
trigger={t("edit", "Edit")}
/>,
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description={t(
"deleteDescription",
"This action cannot be undone."
)}
key="delete"
onConfirm={async () => {
await deleteAnnouncement({
id: row.id,
});
toast.success(t("deleteSuccess", "Deleted successfully"));
ref.current?.refresh();
}}
title={t("confirmDelete", "Confirm Delete")}
trigger={
<Button variant="destructive">{t("delete", "Delete")}</Button>
}
/>,
];
},
batchRender(rows) {
return [
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description={t(
"deleteDescription",
"This action cannot be undone."
)}
key="delete"
onConfirm={async () => {
for (const element of rows) {
await deleteAnnouncement({
id: element.id!,
});
}
toast.success(t("deleteSuccess", "Deleted successfully"));
ref.current?.refresh();
}}
title={t("confirmDelete", "Confirm Delete")}
trigger={
<Button variant="destructive">{t("delete", "Delete")}</Button>
}
/>,
];
},
}}
columns={[
{
accessorKey: "show",
header: t("show", "Show"),
cell: ({ row }) => (
<Switch
defaultChecked={row.getValue("show")}
onCheckedChange={async (checked) => {
await updateAnnouncement({
...row.original,
show: checked,
});
ref.current?.refresh();
}}
/>
),
},
{
accessorKey: "pinned",
header: t("pinned", "Pinned"),
cell: ({ row }) => (
<Switch
defaultChecked={row.getValue("pinned")}
onCheckedChange={async (checked) => {
await updateAnnouncement({
...row.original,
pinned: checked,
});
ref.current?.refresh();
}}
/>
),
},
{
accessorKey: "popup",
header: t("popup", "Popup"),
cell: ({ row }) => (
<Switch
defaultChecked={row.getValue("popup")}
onCheckedChange={async (checked) => {
await updateAnnouncement({
...row.original,
popup: checked,
});
ref.current?.refresh();
}}
/>
),
},
{
accessorKey: "title",
header: t("title", "Title"),
},
{
accessorKey: "content",
header: t("content", "Content"),
},
{
accessorKey: "updated_at",
header: t("updatedAt", "Updated At"),
cell: ({ row }) =>
format(row.getValue("updated_at"), "yyyy-MM-dd HH:mm:ss"),
},
]}
header={{
title: t("announcementList", "Announcement List"),
toolbar: (
<NoticeForm<API.CreateAnnouncementRequest>
loading={loading}
onSubmit={async (values) => {
setLoading(true);
try {
await createAnnouncement(values);
toast.success(t("createSuccess", "Created successfully"));
ref.current?.refresh();
setLoading(false);
return true;
} catch {
setLoading(false);
return false;
}
}}
title={t("createAnnouncement", "Create Announcement")}
trigger={t("create", "Create")}
/>
),
}}
params={[
{
key: "enable",
placeholder: t("enable", "Enable"),
options: [
{ label: t("show", "Show"), value: "false" },
{ label: t("hide", "Hide"), value: "true" },
],
},
{ key: "search" },
]}
request={async (pagination, filter) => {
const { data } = await getAnnouncementList({
...pagination,
...filter,
});
return {
list: data.data?.list || [],
total: data.data?.total || 0,
};
}}
/>
);
}
@@ -0,0 +1,149 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@workspace/ui/components/button";
import {
Form,
FormControl,
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 {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@workspace/ui/components/sheet";
import { MarkdownEditor } from "@workspace/ui/composed/editor/markdown";
import { Icon } from "@workspace/ui/composed/icon";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { z } from "zod";
const formSchema = z.object({
title: z.string(),
content: z.string().optional(),
});
interface AnnouncementFormProps<T> {
onSubmit: (data: T) => Promise<boolean> | boolean;
initialValues?: T;
loading?: boolean;
trigger: string;
title: string;
}
export default function AnnouncementForm<T extends Record<string, any>>({
onSubmit,
initialValues,
loading,
trigger,
title,
}: AnnouncementFormProps<T>) {
const { t } = useTranslation("announcement");
const [open, setOpen] = useState(false);
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: {
title: "",
content: "",
...initialValues,
},
});
useEffect(() => {
form?.reset(initialValues);
}, [form, initialValues]);
async function handleSubmit(data: { [x: string]: any }) {
const bool = await onSubmit(data as T);
if (bool) setOpen(false);
}
return (
<Sheet onOpenChange={setOpen} open={open}>
<SheetTrigger asChild>
<Button
onClick={() => {
form.reset();
setOpen(true);
}}
>
{trigger}
</Button>
</SheetTrigger>
<SheetContent className="w-[800px] max-w-full md:max-w-screen-md">
<SheetHeader>
<SheetTitle>{title}</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100vh-48px-36px-36px-env(safe-area-inset-top))] px-6">
<Form {...form}>
<form
className="space-y-4 pt-4"
id="notice-form"
onSubmit={form.handleSubmit(handleSubmit)}
>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>{t("form.title", "Title")}</FormLabel>
<FormControl>
<Input
placeholder={t("form.titlePlaceholder", "Enter title")}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="content"
render={({ field }) => (
<FormItem>
<FormLabel>{t("form.content", "Content")}</FormLabel>
<FormControl>
<MarkdownEditor
onChange={(value) => {
form.setValue(field.name, value || "");
}}
value={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<SheetFooter className="flex-row justify-end gap-2 pt-3">
<Button
disabled={loading}
onClick={() => {
setOpen(false);
}}
variant="outline"
>
{t("form.cancel", "Cancel")}
</Button>
<Button disabled={loading} onClick={form.handleSubmit(handleSubmit)}>
{loading && (
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
)}{" "}
{t("form.confirm", "Confirm")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,318 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@workspace/ui/components/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@workspace/ui/components/form";
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 { Textarea } from "@workspace/ui/components/textarea";
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
import { Icon } from "@workspace/ui/composed/icon";
import {
getAuthMethodConfig,
updateAuthMethodConfig,
} from "@workspace/ui/services/admin/authMethod";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { z } from "zod";
const appleSchema = z.object({
enabled: z.boolean(),
config: z
.object({
team_id: z.string().optional(),
key_id: z.string().optional(),
client_id: z.string().optional(),
client_secret: z.string().optional(),
redirect_url: z.string().optional(),
})
.optional(),
});
type AppleFormData = z.infer<typeof appleSchema>;
export default function AppleForm() {
const { t } = useTranslation("auth-control");
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const { data, refetch } = useQuery({
queryKey: ["getAuthMethodConfig", "apple"],
queryFn: async () => {
const { data } = await getAuthMethodConfig({
method: "apple",
});
return data.data;
},
enabled: open,
});
const form = useForm<AppleFormData>({
resolver: zodResolver(appleSchema),
defaultValues: {
enabled: false,
config: {
team_id: "",
key_id: "",
client_id: "",
client_secret: "",
redirect_url: "",
},
},
});
useEffect(() => {
if (data) {
form.reset({
enabled: data.enabled,
config: {
team_id: data.config?.team_id || "",
key_id: data.config?.key_id || "",
client_id: data.config?.client_id || "",
client_secret: data.config?.client_secret || "",
redirect_url: data.config?.redirect_url || "",
},
});
}
}, [data, form]);
async function onSubmit(values: AppleFormData) {
setLoading(true);
try {
await updateAuthMethodConfig({
...data,
enabled: values.enabled,
config: {
...data?.config,
...values.config,
},
} as API.UpdateAuthMethodConfigRequest);
toast.success(t("common.saveSuccess", "Saved successfully"));
refetch();
setOpen(false);
} catch (_error) {
toast.error(t("common.saveFailed", "Save failed"));
} 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:apple" />
</div>
<div className="flex-1">
<p className="font-medium">{t("apple.title", "Apple Sign-In")}</p>
<p className="text-muted-foreground text-sm">
{t(
"apple.description",
"Authenticate users with Apple accounts"
)}
</p>
</div>
</div>
<Icon className="size-6" icon="mdi:chevron-right" />
</div>
</SheetTrigger>
<SheetContent className="w-[500px] max-w-full md:max-w-screen-md">
<SheetHeader>
<SheetTitle>{t("apple.title", "Apple Sign-In")}</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-24px-env(safe-area-inset-top))] px-6">
<Form {...form}>
<form
className="space-y-2 pt-4"
id="apple-form"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem>
<FormLabel>{t("apple.enable", "Enable")}</FormLabel>
<FormControl>
<Switch
checked={field.value}
className="!mt-0 float-end"
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t(
"apple.enableDescription",
"When enabled, users can sign in with their Apple ID"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.team_id"
render={({ field }) => (
<FormItem>
<FormLabel>{t("apple.teamId", "Team ID")}</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={field.onChange}
placeholder="ABCDE1FGHI"
value={field.value}
/>
</FormControl>
<FormDescription>
{t("apple.teamIdDescription", "Apple Developer Team ID")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.key_id"
render={({ field }) => (
<FormItem>
<FormLabel>{t("apple.keyId", "Key ID")}</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={field.onChange}
placeholder="ABC1234567"
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"apple.keyIdDescription",
"Your private key ID from Apple Developer Portal"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.client_id"
render={({ field }) => (
<FormItem>
<FormLabel>{t("apple.clientId", "Service ID")}</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={field.onChange}
placeholder="com.your.app.service"
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"apple.clientIdDescription",
"Apple Service ID, available from Apple Developer Portal"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.client_secret"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("apple.clientSecret", "Private Key")}
</FormLabel>
<FormControl>
<Textarea
className="h-20"
onChange={field.onChange}
placeholder={
"-----BEGIN PRIVATE KEY-----\nMIGTAgEA...\n-----END PRIVATE KEY-----"
}
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"apple.clientSecretDescription",
"Private key content (.p8 file) for authenticating with Apple"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.redirect_url"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("apple.redirectUri", "Redirect URL")}
</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={field.onChange}
placeholder="https://your-domain.com"
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"apple.redirectUriDescription",
"API address for redirect URL after successful Apple authentication. Do not end with /"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<SheetFooter className="flex-row justify-end gap-2 pt-3">
<Button
disabled={loading}
onClick={() => setOpen(false)}
variant="outline"
>
{t("common.cancel", "Cancel")}
</Button>
<Button disabled={loading} form="apple-form" type="submit">
{loading && (
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
)}
{t("common.save", "Save")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,295 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@workspace/ui/components/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@workspace/ui/components/form";
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 { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
import { Icon } from "@workspace/ui/composed/icon";
import {
getAuthMethodConfig,
updateAuthMethodConfig,
} from "@workspace/ui/services/admin/authMethod";
import { uid } from "radash";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { z } from "zod";
const deviceSchema = z.object({
id: z.number(),
method: z.string(),
enabled: z.boolean(),
config: z
.object({
show_ads: z.boolean().optional(),
only_real_device: z.boolean().optional(),
enable_security: z.boolean().optional(),
security_secret: z.string().optional(),
})
.optional(),
});
type DeviceFormData = z.infer<typeof deviceSchema>;
export default function DeviceForm() {
const { t } = useTranslation("auth-control");
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const { data, refetch } = useQuery({
queryKey: ["getAuthMethodConfig", "device"],
queryFn: async () => {
const { data } = await getAuthMethodConfig({
method: "device",
});
return data.data;
},
enabled: open,
});
const form = useForm<DeviceFormData>({
resolver: zodResolver(deviceSchema),
defaultValues: {
id: 0,
method: "device",
enabled: false,
config: {
show_ads: false,
only_real_device: false,
enable_security: false,
security_secret: "",
},
},
});
useEffect(() => {
if (data) {
form.reset(data);
}
}, [data, form]);
async function onSubmit(values: DeviceFormData) {
setLoading(true);
try {
await updateAuthMethodConfig(values as API.UpdateAuthMethodConfigRequest);
toast.success(t("common.saveSuccess", "Saved successfully"));
refetch();
setOpen(false);
} catch (_error) {
toast.error(t("common.saveFailed", "Save failed"));
} finally {
setLoading(false);
}
}
function generateSecurityKey() {
const id = uid(32).toLowerCase();
const formatted = `${id.slice(0, 8)}-${id.slice(8, 12)}-${id.slice(12, 16)}-${id.slice(16, 20)}-${id.slice(20)}`;
form.setValue("config.security_secret", formatted);
}
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:devices" />
</div>
<div className="flex-1">
<p className="font-medium">
{t("device.title", "Device Sign-In")}
</p>
<p className="text-muted-foreground text-sm">
{t("device.description", "Authenticate users with device")}
</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("device.title", "Device Sign-In")}</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-24px-env(safe-area-inset-top))] px-6">
<Form {...form}>
<form
className="space-y-2 pt-4"
id="device-form"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem>
<FormLabel>{t("device.enable", "Enable")}</FormLabel>
<FormControl>
<Switch
checked={field.value}
className="!mt-0 float-end"
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t(
"device.enableDescription",
"When enabled, users can sign in with device"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.show_ads"
render={({ field }) => (
<FormItem>
<FormLabel>{t("device.showAds", "Show Ads")}</FormLabel>
<FormControl>
<Switch
checked={field.value}
className="!mt-0 float-end"
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t(
"device.showAdsDescription",
"When enabled, ads will be shown"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.only_real_device"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("device.blockVirtualMachine", "Block Virtual Machine")}
</FormLabel>
<FormControl>
<Switch
checked={field.value}
className="!mt-0 float-end"
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t(
"device.blockVirtualMachineDescription",
"Block virtual machine login, only allow real device"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.enable_security"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("device.enableSecurity", "Enable Security")}
</FormLabel>
<FormControl>
<Switch
checked={field.value}
className="!mt-0 float-end"
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t(
"device.enableSecurityDescription",
"When enabled, application requests must carry communication key"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.security_secret"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("device.communicationKey", "Communication Key")}
</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={field.onChange}
placeholder="e.g., 12345678-1234-1234-1234-123456789abc"
suffix={
<div className="flex h-9 items-center text-nowrap bg-muted px-3">
<Icon
className="size-4 cursor-pointer"
icon="mdi:dice-multiple"
onClick={generateSecurityKey}
/>
</div>
}
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"device.communicationKeyDescription",
"The key used for secure communication between application and server"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<SheetFooter className="flex-row justify-end gap-2 pt-3">
<Button
disabled={loading}
onClick={() => setOpen(false)}
variant="outline"
>
{t("common.cancel", "Cancel")}
</Button>
<Button disabled={loading} form="device-form" type="submit">
{loading && (
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
)}
{t("common.save", "Save")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,865 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@workspace/ui/components/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@workspace/ui/components/form";
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 {
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 {
getAuthMethodConfig,
testEmailSend,
updateAuthMethodConfig,
} from "@workspace/ui/services/admin/authMethod";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { z } from "zod";
const emailSettingsSchema = z.object({
id: z.number(),
method: z.string(),
enabled: z.boolean(),
config: z
.object({
enable_verify: z.boolean(),
enable_domain_suffix: z.boolean(),
domain_suffix_list: z.string().optional(),
verify_email_template: z.string().optional(),
expiration_email_template: z.string().optional(),
maintenance_email_template: z.string().optional(),
traffic_exceed_email_template: z.string().optional(),
platform: z.string(),
platform_config: z
.object({
host: z.string().optional(),
port: z.number().optional(),
ssl: z.boolean(),
user: z.string().optional(),
pass: z.string().optional(),
from: z.string().optional(),
})
.optional(),
})
.optional(),
});
type EmailSettingsFormData = z.infer<typeof emailSettingsSchema>;
export default function EmailSettingsForm() {
const { t } = useTranslation("auth-control");
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [testEmail, setTestEmail] = useState<string>();
const { data, refetch, isFetching } = useQuery({
queryKey: ["getAuthMethodConfig", "email"],
queryFn: async () => {
const { data } = await getAuthMethodConfig({
method: "email",
});
return data.data;
},
enabled: open,
});
const form = useForm<EmailSettingsFormData>({
resolver: zodResolver(emailSettingsSchema),
defaultValues: {
id: 0,
method: "email",
enabled: false,
config: {
enable_verify: false,
enable_domain_suffix: false,
domain_suffix_list: "",
verify_email_template: "",
expiration_email_template: "",
maintenance_email_template: "",
traffic_exceed_email_template: "",
platform: "smtp",
platform_config: {
host: "",
port: 587,
ssl: false,
user: "",
pass: "",
from: "",
},
},
},
});
useEffect(() => {
if (data) {
form.reset(data);
}
}, [data, form]);
async function onSubmit(values: EmailSettingsFormData) {
setLoading(true);
try {
await updateAuthMethodConfig({
...values,
config: {
...values.config,
platform: "smtp",
},
} as API.UpdateAuthMethodConfigRequest);
toast.success(t("common.saveSuccess", "Saved successfully"));
refetch();
setOpen(false);
} catch (_error) {
toast.error(t("common.saveFailed", "Save failed"));
} 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-outline" />
</div>
<div className="flex-1">
<p className="font-medium">
{t("email.title", "Email Settings")}
</p>
<p className="text-muted-foreground text-sm">
{t(
"email.description",
"Configure email authentication and templates"
)}
</p>
</div>
</div>
<Icon className="size-6" icon="mdi:chevron-right" />
</div>
</SheetTrigger>
<SheetContent className="md:!max-w-screen-lg max-w-full">
<SheetHeader>
<SheetTitle>{t("email.title", "Email Settings")}</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-24px-env(safe-area-inset-top))] px-6">
<Form {...form}>
<form
className="space-y-2 pt-4"
id="email-settings-form"
onSubmit={form.handleSubmit(onSubmit)}
>
<Tabs className="space-y-2" defaultValue="basic">
<TabsList className="flex h-full w-full flex-wrap *:flex-auto md:flex-nowrap">
<TabsTrigger value="basic">
{t("email.basicSettings", "Basic Settings")}
</TabsTrigger>
<TabsTrigger value="smtp">
{t("email.smtpSettings", "SMTP Settings")}
</TabsTrigger>
<TabsTrigger value="verify">
{t("email.verifyTemplate", "Verify Template")}
</TabsTrigger>
<TabsTrigger value="expiration">
{t("email.expirationTemplate", "Expiration Template")}
</TabsTrigger>
<TabsTrigger value="maintenance">
{t("email.maintenanceTemplate", "Maintenance Template")}
</TabsTrigger>
<TabsTrigger value="traffic">
{t("email.trafficTemplate", "Traffic Template")}
</TabsTrigger>
</TabsList>
<TabsContent className="space-y-2" value="basic">
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem>
<FormLabel>{t("email.enable", "Enable")}</FormLabel>
<FormControl>
<Switch
checked={field.value}
className="!mt-0 float-end"
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t(
"email.enableDescription",
"When enabled, users can sign in with email"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.enable_verify"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("email.emailVerification", "Email Verification")}
</FormLabel>
<FormControl>
<Switch
checked={field.value}
className="!mt-0 float-end"
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t(
"email.emailVerificationDescription",
"Require email verification for new users"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.enable_domain_suffix"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"email.emailSuffixWhitelist",
"Email Suffix Whitelist"
)}
</FormLabel>
<FormControl>
<Switch
checked={field.value}
className="!mt-0 float-end"
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t(
"email.emailSuffixWhitelistDescription",
"Only allow emails from whitelisted domains"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.domain_suffix_list"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("email.whitelistSuffixes", "Whitelist Suffixes")}
</FormLabel>
<FormControl>
<Textarea
className="h-32"
onChange={field.onChange}
placeholder={t(
"email.whitelistSuffixesPlaceholder",
"gmail.com, outlook.com"
)}
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"email.whitelistSuffixesDescription",
"One domain suffix per line"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</TabsContent>
<TabsContent className="space-y-2" value="smtp">
<FormField
control={form.control}
name="config.platform_config.host"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("email.smtpServerAddress", "SMTP Server Address")}
</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={field.onChange}
placeholder={t(
"email.inputPlaceholder",
"Please enter"
)}
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"email.smtpServerAddressDescription",
"The SMTP server hostname"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.platform_config.port"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("email.smtpServerPort", "SMTP Server Port")}
</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={(value) =>
field.onChange(Number(value))
}
placeholder="587"
type="number"
value={field.value?.toString()}
/>
</FormControl>
<FormDescription>
{t(
"email.smtpServerPortDescription",
"The SMTP server port (usually 25, 465, or 587)"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.platform_config.ssl"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"email.smtpEncryptionMethod",
"SSL/TLS Encryption"
)}
</FormLabel>
<FormControl>
<Switch
checked={field.value}
className="!mt-0 float-end"
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t(
"email.smtpEncryptionMethodDescription",
"Enable SSL/TLS encryption for SMTP connection"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.platform_config.user"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("email.smtpAccount", "SMTP Account")}
</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={field.onChange}
placeholder={t(
"email.inputPlaceholder",
"Please enter"
)}
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"email.smtpAccountDescription",
"The SMTP authentication username"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.platform_config.pass"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("email.smtpPassword", "SMTP Password")}
</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={field.onChange}
placeholder={t(
"email.inputPlaceholder",
"Please enter"
)}
type="password"
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"email.smtpPasswordDescription",
"The SMTP authentication password"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.platform_config.from"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("email.senderAddress", "Sender Address")}
</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={field.onChange}
placeholder={t(
"email.inputPlaceholder",
"Please enter"
)}
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"email.senderAddressDescription",
"The email address that appears in the From field"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-2 border-t pt-4">
<FormLabel>
{t("email.sendTestEmail", "Send Test Email")}
</FormLabel>
<div className="flex items-center gap-2">
<EnhancedInput
onValueChange={(value) => setTestEmail(value as string)}
placeholder="test@example.com"
type="email"
value={testEmail}
/>
<Button
disabled={!testEmail || isFetching}
onClick={async () => {
if (!testEmail) return;
try {
await testEmailSend({ email: testEmail });
toast.success(
t("email.sendSuccess", "Email sent successfully")
);
} catch {
toast.error(
t("email.sendFailure", "Email send failed")
);
}
}}
type="button"
>
{t("email.sendTestEmail", "Send Test Email")}
</Button>
</div>
<p className="text-muted-foreground text-xs">
{t(
"email.sendTestEmailDescription",
"Send a test email to verify your SMTP configuration"
)}
</p>
</div>
</TabsContent>
<TabsContent className="space-y-2" value="verify">
<FormField
control={form.control}
name="config.verify_email_template"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"email.verifyEmailTemplate",
"Verify Email Template"
)}
</FormLabel>
<FormControl>
<HTMLEditor
onBlur={field.onChange}
placeholder={t(
"email.inputPlaceholder",
"Please enter"
)}
value={field.value}
/>
</FormControl>
<div className="mt-4 space-y-2 border-t pt-4">
<p className="font-medium text-muted-foreground text-sm">
{t(
"email.templateVariables.title",
"Template Variables"
)}
</p>
<div className="space-y-2 text-muted-foreground text-xs">
<div className="flex items-center gap-2">
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
{"{{.Type}}"}
</code>
<span>
{t(
"email.templateVariables.type.description",
"Email type (1: Register, 2: Reset Password)"
)}
</span>
</div>
<div className="pl-6 text-orange-600 dark:text-orange-400">
💡{" "}
{t(
"email.templateVariables.type.conditionalSyntax",
"Use conditional syntax to display different content"
)}
<br />
<code className="rounded bg-orange-50 px-1 text-xs dark:bg-orange-900/20">
{"{{if eq .Type 1}}...{{else}}...{{end}}"}
</code>
</div>
<div className="flex items-center gap-2">
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
{"{{.SiteLogo}}"}
</code>
<span>
{t(
"email.templateVariables.siteLogo.description",
"Site logo URL"
)}
</span>
</div>
<div className="flex items-center gap-2">
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
{"{{.SiteName}}"}
</code>
<span>
{t(
"email.templateVariables.siteName.description",
"Site name"
)}
</span>
</div>
<div className="flex items-center gap-2">
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
{"{{.Expire}}"}
</code>
<span>
{t(
"email.templateVariables.expire.description",
"Code expiration time"
)}
</span>
</div>
<div className="flex items-center gap-2">
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
{"{{.Code}}"}
</code>
<span>
{t(
"email.templateVariables.code.description",
"Verification code"
)}
</span>
</div>
</div>
</div>
<FormMessage />
</FormItem>
)}
/>
</TabsContent>
<TabsContent className="space-y-2" value="expiration">
<FormField
control={form.control}
name="config.expiration_email_template"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"email.expirationEmailTemplate",
"Expiration Email Template"
)}
</FormLabel>
<FormControl>
<HTMLEditor
onBlur={field.onChange}
placeholder={t(
"email.inputPlaceholder",
"Please enter"
)}
value={field.value}
/>
</FormControl>
<div className="mt-4 space-y-2 border-t pt-4">
<p className="font-medium text-muted-foreground text-sm">
{t(
"email.templateVariables.title",
"Template Variables"
)}
</p>
<div className="space-y-2 text-muted-foreground text-xs">
<div className="flex items-center gap-2">
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
{"{{.SiteLogo}}"}
</code>
<span>
{t(
"email.templateVariables.siteLogo.description",
"Site logo URL"
)}
</span>
</div>
<div className="flex items-center gap-2">
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
{"{{.SiteName}}"}
</code>
<span>
{t(
"email.templateVariables.siteName.description",
"Site name"
)}
</span>
</div>
<div className="flex items-center gap-2">
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
{"{{.ExpireDate}}"}
</code>
<span>
{t(
"email.templateVariables.expireDate.description",
"Subscription expiration date"
)}
</span>
</div>
</div>
</div>
<FormMessage />
</FormItem>
)}
/>
</TabsContent>
<TabsContent className="space-y-2" value="maintenance">
<FormField
control={form.control}
name="config.maintenance_email_template"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"email.maintenanceEmailTemplate",
"Maintenance Email Template"
)}
</FormLabel>
<FormControl>
<HTMLEditor
onBlur={field.onChange}
placeholder={t(
"email.inputPlaceholder",
"Please enter"
)}
value={field.value}
/>
</FormControl>
<div className="mt-4 space-y-2 border-t pt-4">
<p className="font-medium text-muted-foreground text-sm">
{t(
"email.templateVariables.title",
"Template Variables"
)}
</p>
<div className="space-y-2 text-muted-foreground text-xs">
<div className="flex items-center gap-2">
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
{"{{.SiteLogo}}"}
</code>
<span>
{t(
"email.templateVariables.siteLogo.description",
"Site logo URL"
)}
</span>
</div>
<div className="flex items-center gap-2">
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
{"{{.SiteName}}"}
</code>
<span>
{t(
"email.templateVariables.siteName.description",
"Site name"
)}
</span>
</div>
<div className="flex items-center gap-2">
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
{"{{.MaintenanceDate}}"}
</code>
<span>
{t(
"email.templateVariables.maintenanceDate.description",
"Maintenance date"
)}
</span>
</div>
<div className="flex items-center gap-2">
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
{"{{.MaintenanceTime}}"}
</code>
<span>
{t(
"email.templateVariables.maintenanceTime.description",
"Maintenance time"
)}
</span>
</div>
</div>
</div>
<FormMessage />
</FormItem>
)}
/>
</TabsContent>
<TabsContent className="space-y-2" value="traffic">
<FormField
control={form.control}
name="config.traffic_exceed_email_template"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"email.trafficExceedEmailTemplate",
"Traffic Exceed Email Template"
)}
</FormLabel>
<FormControl>
<HTMLEditor
onBlur={field.onChange}
placeholder={t(
"email.inputPlaceholder",
"Please enter"
)}
value={field.value}
/>
</FormControl>
<div className="mt-4 space-y-2 border-t pt-4">
<p className="font-medium text-muted-foreground text-sm">
{t(
"email.templateVariables.title",
"Template Variables"
)}
</p>
<div className="space-y-2 text-muted-foreground text-xs">
<div className="flex items-center gap-2">
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
{"{{.SiteLogo}}"}
</code>
<span>
{t(
"email.templateVariables.siteLogo.description",
"Site logo URL"
)}
</span>
</div>
<div className="flex items-center gap-2">
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
{"{{.SiteName}}"}
</code>
<span>
{t(
"email.templateVariables.siteName.description",
"Site name"
)}
</span>
</div>
</div>
</div>
<FormMessage />
</FormItem>
)}
/>
</TabsContent>
</Tabs>
</form>
</Form>
</ScrollArea>
<SheetFooter className="flex-row justify-end gap-2 pt-3">
<Button
disabled={loading}
onClick={() => setOpen(false)}
variant="outline"
>
{t("common.cancel", "Cancel")}
</Button>
<Button disabled={loading} form="email-settings-form" type="submit">
{loading && (
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
)}
{t("common.save", "Save")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,229 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@workspace/ui/components/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@workspace/ui/components/form";
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 { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
import { Icon } from "@workspace/ui/composed/icon";
import {
getAuthMethodConfig,
updateAuthMethodConfig,
} from "@workspace/ui/services/admin/authMethod";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { z } from "zod";
const facebookSchema = z.object({
enabled: z.boolean(),
client_id: z.string().optional(),
client_secret: z.string().optional(),
});
type FacebookFormData = z.infer<typeof facebookSchema>;
export default function FacebookForm() {
const { t } = useTranslation("auth-control");
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const { data, refetch } = useQuery({
queryKey: ["getAuthMethodConfig", "facebook"],
queryFn: async () => {
const { data } = await getAuthMethodConfig({
method: "facebook",
});
return data.data;
},
enabled: open,
});
const form = useForm<FacebookFormData>({
resolver: zodResolver(facebookSchema),
defaultValues: {
enabled: false,
client_id: "",
client_secret: "",
},
});
useEffect(() => {
if (data) {
form.reset({
enabled: data.enabled,
client_id: data.config?.client_id || "",
client_secret: data.config?.client_secret || "",
});
}
}, [data, form]);
async function onSubmit(values: FacebookFormData) {
setLoading(true);
try {
await updateAuthMethodConfig({
...data,
enabled: values.enabled,
config: {
...data?.config,
client_id: values.client_id,
client_secret: values.client_secret,
},
} as API.UpdateAuthMethodConfigRequest);
toast.success(t("common.saveSuccess", "Saved successfully"));
refetch();
setOpen(false);
} catch (_error) {
toast.error(t("common.saveFailed", "Save failed"));
} 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:facebook" />
</div>
<div className="flex-1">
<p className="font-medium">
{t("facebook.title", "Facebook Sign-In")}
</p>
<p className="text-muted-foreground text-sm">
{t(
"facebook.description",
"Authenticate users with Facebook accounts"
)}
</p>
</div>
</div>
<Icon className="size-6" icon="mdi:chevron-right" />
</div>
</SheetTrigger>
<SheetContent className="w-[500px] max-w-full md:max-w-screen-md">
<SheetHeader>
<SheetTitle>{t("facebook.title", "Facebook Sign-In")}</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-24px-env(safe-area-inset-top))] px-6">
<Form {...form}>
<form
className="space-y-2 pt-4"
id="facebook-form"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem>
<FormLabel>{t("facebook.enable", "Enable")}</FormLabel>
<FormControl>
<Switch
checked={field.value}
className="!mt-0 float-end"
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t(
"facebook.enableDescription",
"When enabled, users can sign in with their Facebook account"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="client_id"
render={({ field }) => (
<FormItem>
<FormLabel>{t("facebook.clientId", "App ID")}</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={field.onChange}
placeholder="1234567890123456"
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"facebook.clientIdDescription",
"Facebook App ID, available from Facebook Developer Portal"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="client_secret"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("facebook.clientSecret", "App Secret")}
</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={field.onChange}
placeholder="1234567890abcdef1234567890abcdef"
type="password"
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"facebook.clientSecretDescription",
"Facebook App Secret, available from Facebook Developer Portal"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<SheetFooter className="flex-row justify-end gap-2 pt-3">
<Button
disabled={loading}
onClick={() => setOpen(false)}
variant="outline"
>
{t("common.cancel", "Cancel")}
</Button>
<Button disabled={loading} form="facebook-form" type="submit">
{loading && (
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
)}
{t("common.save", "Save")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,230 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@workspace/ui/components/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@workspace/ui/components/form";
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 { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
import { Icon } from "@workspace/ui/composed/icon";
import {
getAuthMethodConfig,
updateAuthMethodConfig,
} from "@workspace/ui/services/admin/authMethod";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { z } from "zod";
const githubSchema = z.object({
enabled: z.boolean(),
client_id: z.string().optional(),
client_secret: z.string().optional(),
});
type GithubFormData = z.infer<typeof githubSchema>;
export default function GithubForm() {
const { t } = useTranslation("auth-control");
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const { data, refetch } = useQuery({
queryKey: ["getAuthMethodConfig", "github"],
queryFn: async () => {
const { data } = await getAuthMethodConfig({
method: "github",
});
return data.data;
},
enabled: open,
});
const form = useForm<GithubFormData>({
resolver: zodResolver(githubSchema),
defaultValues: {
enabled: false,
client_id: "",
client_secret: "",
},
});
useEffect(() => {
if (data) {
form.reset({
enabled: data.enabled,
client_id: data.config?.client_id || "",
client_secret: data.config?.client_secret || "",
});
}
}, [data, form]);
async function onSubmit(values: GithubFormData) {
setLoading(true);
try {
await updateAuthMethodConfig({
...data,
enabled: values.enabled,
config: {
...data?.config,
client_id: values.client_id,
client_secret: values.client_secret,
},
} as API.UpdateAuthMethodConfigRequest);
toast.success(t("common.saveSuccess", "Saved successfully"));
refetch();
setOpen(false);
} catch (_error) {
toast.error(t("common.saveFailed", "Save failed"));
} 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:github" />
</div>
<div className="flex-1">
<p className="font-medium">
{t("github.title", "GitHub Sign-In")}
</p>
<p className="text-muted-foreground text-sm">
{t(
"github.description",
"Authenticate users with GitHub accounts"
)}
</p>
</div>
</div>
<Icon className="size-6" icon="mdi:chevron-right" />
</div>
</SheetTrigger>
<SheetContent className="w-[500px] max-w-full md:max-w-screen-md">
<SheetHeader>
<SheetTitle>{t("github.title", "GitHub Sign-In")}</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-24px-env(safe-area-inset-top))] px-6">
<Form {...form}>
<form
className="space-y-2 pt-4"
id="github-form"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem>
<FormLabel>{t("github.enable", "Enable")}</FormLabel>
<FormControl>
<Switch
checked={field.value}
className="!mt-0 float-end"
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t(
"github.enableDescription",
"When enabled, users can sign in with their GitHub account"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="client_id"
render={({ field }) => (
<FormItem>
<FormLabel>{t("github.clientId", "Client ID")}</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={field.onChange}
placeholder="e.g., Iv1.1234567890abcdef"
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"github.clientIdDescription",
"GitHub OAuth App Client ID, available from GitHub Developer Settings"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="client_secret"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("github.clientSecret", "Client Secret")}
</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={field.onChange}
placeholder="e.g., 1234567890abcdef1234567890abcdef12345678"
type="password"
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"github.clientSecretDescription",
"GitHub OAuth App Client Secret, available from GitHub Developer Settings"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<SheetFooter className="flex-row justify-end gap-2 pt-3">
<Button
disabled={loading}
onClick={() => setOpen(false)}
variant="outline"
>
{t("common.cancel", "Cancel")}
</Button>
<Button disabled={loading} form="github-form" type="submit">
{loading && (
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
)}
{t("common.save", "Save")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,227 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@workspace/ui/components/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@workspace/ui/components/form";
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 { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
import { Icon } from "@workspace/ui/composed/icon";
import {
getAuthMethodConfig,
updateAuthMethodConfig,
} from "@workspace/ui/services/admin/authMethod";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { z } from "zod";
const googleSchema = z.object({
id: z.number(),
method: z.string().default("google").optional(),
enabled: z.boolean().default(false).optional(),
config: z
.object({
client_id: z.string().optional(),
client_secret: z.string().optional(),
})
.optional(),
});
type GoogleFormData = z.infer<typeof googleSchema>;
export default function GoogleForm() {
const { t } = useTranslation("auth-control");
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const { data, refetch } = useQuery({
queryKey: ["getAuthMethodConfig", "google"],
queryFn: async () => {
const { data } = await getAuthMethodConfig({
method: "google",
});
return data.data;
},
enabled: open,
});
const form = useForm<GoogleFormData>({
resolver: zodResolver(googleSchema),
defaultValues: {
id: 0,
method: "google",
enabled: false,
config: {
client_id: "",
client_secret: "",
},
},
});
useEffect(() => {
if (data) {
form.reset(data);
}
}, [data, form]);
async function onSubmit(values: GoogleFormData) {
setLoading(true);
try {
await updateAuthMethodConfig(values as API.UpdateAuthMethodConfigRequest);
toast.success(t("common.saveSuccess", "Saved successfully"));
refetch();
setOpen(false);
} catch (_error) {
toast.error(t("common.saveFailed", "Save failed"));
} 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:google" />
</div>
<div className="flex-1">
<p className="font-medium">
{t("google.title", "Google Sign-In")}
</p>
<p className="text-muted-foreground text-sm">
{t(
"google.description",
"Authenticate users with Google accounts"
)}
</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("google.title", "Google Sign-In")}</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-24px-env(safe-area-inset-top))] px-6">
<Form {...form}>
<form
className="space-y-2 pt-4"
id="google-form"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem>
<FormLabel>{t("google.enable", "Enable")}</FormLabel>
<FormControl>
<Switch
checked={field.value}
className="!mt-0 float-end"
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t(
"google.enableDescription",
"When enabled, users can sign in with their Google account"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.client_id"
render={({ field }) => (
<FormItem>
<FormLabel>{t("google.clientId", "Client ID")}</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={field.onChange}
placeholder="123456789-abc123def456.apps.googleusercontent.com"
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"google.clientIdDescription",
"Google OAuth Client ID, available from Google Cloud Console"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.client_secret"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("google.clientSecret", "Client Secret")}
</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={field.onChange}
placeholder="GOCSPX-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
type="password"
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"google.clientSecretDescription",
"Google OAuth Client Secret, available from Google Cloud Console"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<SheetFooter className="flex-row justify-end gap-2 pt-3">
<Button
disabled={loading}
onClick={() => setOpen(false)}
variant="outline"
>
{t("common.cancel", "Cancel")}
</Button>
<Button disabled={loading} form="google-form" type="submit">
{loading && (
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
)}
{t("common.save", "Save")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,622 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { Button } from "@workspace/ui/components/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@workspace/ui/components/form";
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 { Switch } from "@workspace/ui/components/switch";
import { Textarea } from "@workspace/ui/components/textarea";
import { AreaCodeSelect } from "@workspace/ui/composed/area-code-select";
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
import { Icon } from "@workspace/ui/composed/icon";
import TagInput from "@workspace/ui/composed/tag-input";
import {
getAuthMethodConfig,
getSmsPlatform,
testSmsSend,
updateAuthMethodConfig,
} from "@workspace/ui/services/admin/authMethod";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { z } from "zod";
const phoneSettingsSchema = z.object({
id: z.number(),
method: z.string(),
enabled: z.boolean(),
config: z
.object({
enable_whitelist: z.boolean().optional(),
whitelist: z.array(z.string()).optional(),
platform: z.string().optional(),
platform_config: z
.object({
access: z.string().optional(),
endpoint: z.string().optional(),
secret: z.string().optional(),
template_code: z.string().optional(),
sign_name: z.string().optional(),
phone_number: z.string().optional(),
template: z.string().optional(),
})
.optional(),
})
.optional(),
});
type PhoneSettingsFormData = z.infer<typeof phoneSettingsSchema>;
export default function PhoneSettingsForm() {
const { t } = useTranslation("auth-control");
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [testParams, setTestParams] = useState<API.TestSmsSendRequest>({
telephone: "",
area_code: "1",
});
const { data, refetch, isFetching } = useQuery({
queryKey: ["getAuthMethodConfig", "mobile"],
queryFn: async () => {
const { data } = await getAuthMethodConfig({
method: "mobile",
});
return data.data;
},
enabled: open,
});
const { data: platforms } = useQuery({
queryKey: ["getSmsPlatform"],
queryFn: async () => {
const { data } = await getSmsPlatform();
return data.data?.list;
},
enabled: open,
});
const form = useForm<PhoneSettingsFormData>({
resolver: zodResolver(phoneSettingsSchema),
defaultValues: {
id: 0,
method: "mobile",
enabled: false,
config: {
enable_whitelist: false,
whitelist: [],
platform: "",
platform_config: {
access: "",
endpoint: "",
secret: "",
template_code: "code",
sign_name: "",
phone_number: "",
template: "",
},
},
},
});
const selectedPlatform = platforms?.find(
(platform) => platform.platform === form.watch("config.platform")
);
const { platform_url, platform_field_description: platformConfig } =
selectedPlatform ?? {};
useEffect(() => {
if (data) {
form.reset(data);
}
}, [data, form]);
async function onSubmit(values: PhoneSettingsFormData) {
setLoading(true);
try {
await updateAuthMethodConfig(values as API.UpdateAuthMethodConfigRequest);
toast.success(t("common.saveSuccess", "Saved successfully"));
refetch();
setOpen(false);
} catch (_error) {
toast.error(t("common.saveFailed", "Save failed"));
} 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:phone-settings"
/>
</div>
<div className="flex-1">
<p className="font-medium">{t("phone.title", "SMS Settings")}</p>
<p className="text-muted-foreground text-sm">
{t("phone.description", "Configure SMS authentication")}
</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("phone.title", "SMS Settings")}</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-24px-env(safe-area-inset-top))] px-6">
<Form {...form}>
<form
className="space-y-2 pt-4"
id="phone-settings-form"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem>
<FormLabel>{t("phone.enable", "Enable")}</FormLabel>
<FormControl>
<Switch
checked={field.value}
className="!mt-0 float-end"
disabled={isFetching}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t(
"phone.enableTip",
"When enabled, users can sign in with their phone number"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.enable_whitelist"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("phone.whitelistValidation", "Whitelist Validation")}
</FormLabel>
<FormControl>
<Switch
checked={field.value}
className="!mt-0 float-end"
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t(
"phone.whitelistValidationTip",
"Only allow phone numbers with whitelisted area codes"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.whitelist"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("phone.whitelistAreaCode", "Whitelist Area Codes")}
</FormLabel>
<FormControl>
<TagInput
onChange={field.onChange}
placeholder="1, 852, 886, 888"
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"phone.whitelistAreaCodeTip",
"Enter area codes separated by commas"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.platform"
render={({ field }) => (
<FormItem>
<FormLabel>{t("phone.platform", "SMS Platform")}</FormLabel>
<div className="flex items-center gap-1">
<FormControl>
<Select
disabled={isFetching}
onValueChange={field.onChange}
value={field.value}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{platforms?.map((item) => (
<SelectItem
key={item.platform}
value={item.platform}
>
{item.platform}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
{platform_url && (
<Button asChild size="sm">
<Link target="_blank" to={platform_url}>
{t("phone.applyPlatform", "Apply")}
</Link>
</Button>
)}
</div>
<FormDescription>
{t("phone.platformTip", "Select SMS service provider")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.platform_config.access"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("phone.accessLabel", "Access Key")}
</FormLabel>
<FormControl>
<EnhancedInput
disabled={isFetching}
onValueChange={field.onChange}
placeholder={t(
"phone.platformConfigTip",
"Please enter {{key}}",
{
key: platformConfig?.access,
}
)}
value={field.value}
/>
</FormControl>
<FormDescription>
{t("phone.platformConfigTip", "Please enter {{key}}", {
key: platformConfig?.access,
})}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{platformConfig?.endpoint && (
<FormField
control={form.control}
name="config.platform_config.endpoint"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("phone.endpointLabel", "Endpoint")}
</FormLabel>
<FormControl>
<EnhancedInput
disabled={isFetching}
onValueChange={field.onChange}
placeholder={t(
"phone.platformConfigTip",
"Please enter {{key}}",
{
key: platformConfig?.endpoint,
}
)}
value={field.value}
/>
</FormControl>
<FormDescription>
{t("phone.platformConfigTip", "Please enter {{key}}", {
key: platformConfig?.endpoint,
})}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="config.platform_config.secret"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("phone.secretLabel", "Secret Key")}
</FormLabel>
<FormControl>
<EnhancedInput
disabled={isFetching}
onValueChange={field.onChange}
placeholder={t(
"phone.platformConfigTip",
"Please enter {{key}}",
{
key: platformConfig?.secret,
}
)}
type="password"
value={field.value}
/>
</FormControl>
<FormDescription>
{t("phone.platformConfigTip", "Please enter {{key}}", {
key: platformConfig?.secret,
})}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{platformConfig?.template_code && (
<FormField
control={form.control}
name="config.platform_config.template_code"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("phone.templateCodeLabel", "Template Code")}
</FormLabel>
<FormControl>
<EnhancedInput
disabled={isFetching}
onValueChange={field.onChange}
placeholder={t(
"phone.platformConfigTip",
"Please enter {{key}}",
{
key: platformConfig?.template_code,
}
)}
value={field.value}
/>
</FormControl>
<FormDescription>
{t("phone.platformConfigTip", "Please enter {{key}}", {
key: platformConfig?.template_code,
})}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{platformConfig?.sign_name && (
<FormField
control={form.control}
name="config.platform_config.sign_name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("phone.signNameLabel", "Sign Name")}
</FormLabel>
<FormControl>
<EnhancedInput
disabled={isFetching}
onValueChange={field.onChange}
placeholder={t(
"phone.platformConfigTip",
"Please enter {{key}}",
{
key: platformConfig?.sign_name,
}
)}
value={field.value}
/>
</FormControl>
<FormDescription>
{t("phone.platformConfigTip", "Please enter {{key}}", {
key: platformConfig?.sign_name,
})}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{platformConfig?.phone_number && (
<FormField
control={form.control}
name="config.platform_config.phone_number"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("phone.phoneNumberLabel", "Phone Number")}
</FormLabel>
<FormControl>
<EnhancedInput
disabled={isFetching}
onValueChange={field.onChange}
placeholder={t(
"phone.platformConfigTip",
"Please enter {{key}}",
{
key: platformConfig?.phone_number,
}
)}
value={field.value}
/>
</FormControl>
<FormDescription>
{t("phone.platformConfigTip", "Please enter {{key}}", {
key: platformConfig?.phone_number,
})}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{platformConfig?.code_variable && (
<FormField
control={form.control}
name="config.platform_config.template"
render={({ field }) => (
<FormItem>
<FormLabel>{t("phone.template", "Template")}</FormLabel>
<FormControl>
<Textarea
disabled={isFetching}
onChange={field.onChange}
placeholder={t(
"phone.placeholders.template",
"Use {{code}} for verification code",
{
code: platformConfig?.code_variable,
}
)}
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"phone.templateTip",
"Use {{code}} variable for the verification code",
{
code: platformConfig?.code_variable,
}
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
<div className="space-y-4 border-t pt-4">
<div>
<FormLabel>{t("phone.testSms", "Test SMS")}</FormLabel>
<p className="mb-3 text-muted-foreground text-sm">
{t(
"phone.testSmsTip",
"Send a test SMS to verify configuration"
)}
</p>
<div className="flex items-center gap-2">
<AreaCodeSelect
onChange={(value) => {
if (value.phone) {
setTestParams((prev) => ({
...prev,
area_code: value.phone!,
}));
}
}}
value={testParams.area_code}
/>
<EnhancedInput
onValueChange={(value) => {
setTestParams((prev) => ({
...prev,
telephone: value as string,
}));
}}
placeholder={t("phone.testSmsPhone", "Phone number")}
value={testParams.telephone}
/>
<Button
disabled={
!(testParams.telephone && testParams.area_code) ||
isFetching
}
onClick={async () => {
if (
isFetching ||
!testParams.telephone ||
!testParams.area_code
)
return;
try {
await testSmsSend(testParams);
toast.success(
t("phone.sendSuccess", "SMS sent successfully")
);
} catch {
toast.error(t("phone.sendFailed", "SMS send failed"));
}
}}
type="button"
>
{t("phone.testSms", "Test SMS")}
</Button>
</div>
</div>
</div>
</form>
</Form>
</ScrollArea>
<SheetFooter className="flex-row justify-end gap-2 pt-3">
<Button
disabled={loading}
onClick={() => setOpen(false)}
variant="outline"
>
{t("common.cancel", "Cancel")}
</Button>
<Button disabled={loading} form="phone-settings-form" type="submit">
{loading && (
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
)}
{t("common.save", "Save")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,230 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@workspace/ui/components/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@workspace/ui/components/form";
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 { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
import { Icon } from "@workspace/ui/composed/icon";
import {
getAuthMethodConfig,
updateAuthMethodConfig,
} from "@workspace/ui/services/admin/authMethod";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { z } from "zod";
const telegramSchema = z.object({
enabled: z.boolean(),
bot: z.string().optional(),
bot_token: z.string().optional(),
});
type TelegramFormData = z.infer<typeof telegramSchema>;
export default function TelegramForm() {
const { t } = useTranslation("auth-control");
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const { data, refetch } = useQuery({
queryKey: ["getAuthMethodConfig", "telegram"],
queryFn: async () => {
const { data } = await getAuthMethodConfig({
method: "telegram",
});
return data.data;
},
enabled: open,
});
const form = useForm<TelegramFormData>({
resolver: zodResolver(telegramSchema),
defaultValues: {
enabled: false,
bot: "",
bot_token: "",
},
});
useEffect(() => {
if (data) {
form.reset({
enabled: data.enabled,
bot: data.config?.bot || "",
bot_token: data.config?.bot_token || "",
});
}
}, [data, form]);
async function onSubmit(values: TelegramFormData) {
setLoading(true);
try {
await updateAuthMethodConfig({
...data,
enabled: values.enabled,
config: {
...data?.config,
bot: values.bot,
bot_token: values.bot_token,
},
} as API.UpdateAuthMethodConfigRequest);
toast.success(t("common.saveSuccess", "Saved successfully"));
refetch();
setOpen(false);
} catch (_error) {
toast.error(t("common.saveFailed", "Save failed"));
} 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:telegram" />
</div>
<div className="flex-1">
<p className="font-medium">
{t("telegram.title", "Telegram Sign-In")}
</p>
<p className="text-muted-foreground text-sm">
{t(
"telegram.description",
"Authenticate users with Telegram accounts"
)}
</p>
</div>
</div>
<Icon className="size-6" icon="mdi:chevron-right" />
</div>
</SheetTrigger>
<SheetContent className="w-[500px] max-w-full md:max-w-screen-md">
<SheetHeader>
<SheetTitle>{t("telegram.title", "Telegram Sign-In")}</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-24px-env(safe-area-inset-top))] px-6">
<Form {...form}>
<form
className="space-y-2 pt-4"
id="telegram-form"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem>
<FormLabel>{t("telegram.enable", "Enable")}</FormLabel>
<FormControl>
<Switch
checked={field.value}
className="!mt-0 float-end"
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t(
"telegram.enableDescription",
"When enabled, users can sign in with their Telegram account"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="bot"
render={({ field }) => (
<FormItem>
<FormLabel>{t("telegram.clientId", "Bot ID")}</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={field.onChange}
placeholder="6123456789"
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"telegram.clientIdDescription",
"Telegram Bot ID, available from @BotFather"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="bot_token"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("telegram.clientSecret", "Bot Token")}
</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={field.onChange}
placeholder="6123456789:AAHn_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
type="password"
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"telegram.clientSecretDescription",
"Telegram Bot Token, available from @BotFather"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<SheetFooter className="flex-row justify-end gap-2 pt-3">
<Button
disabled={loading}
onClick={() => setOpen(false)}
variant="outline"
>
{t("common.cancel", "Cancel")}
</Button>
<Button disabled={loading} form="telegram-form" type="submit">
{loading && (
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
)}
{t("common.save", "Save")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,67 @@
import {
Table,
TableBody,
TableCell,
TableRow,
} from "@workspace/ui/components/table";
import { useTranslation } from "react-i18next";
import AppleForm from "./forms/apple-form";
import DeviceForm from "./forms/device-form";
import EmailSettingsForm from "./forms/email-settings-form";
import FacebookForm from "./forms/facebook-form";
import GithubForm from "./forms/github-form";
import GoogleForm from "./forms/google-form";
import PhoneSettingsForm from "./forms/phone-settings-form";
import TelegramForm from "./forms/telegram-form";
export default function AuthControl() {
const { t } = useTranslation("auth-control");
const formSections = [
{
title: t("communicationMethods", "Communication Methods"),
forms: [
{ component: EmailSettingsForm },
{ component: PhoneSettingsForm },
],
},
{
title: t("socialAuthMethods", "Social Authentication Methods"),
forms: [
{ component: AppleForm },
{ component: GoogleForm },
{ component: FacebookForm },
{ component: GithubForm },
{ component: TelegramForm },
],
},
{
title: t("deviceAuthMethods", "Device Authentication Methods"),
forms: [{ component: DeviceForm }],
},
];
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,118 @@
"use client";
import { useNavigate } from "@tanstack/react-router";
import {
resetPassword,
userLogin,
userRegister,
} from "@workspace/ui/services/common/auth";
import type { ReactNode } from "react";
import { useState, useTransition } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { USER_EMAIL, USER_PASSWORD } from "@/config";
import { useGlobalStore } from "@/stores/global";
import { getRedirectUrl, setAuthorization } from "@/utils/common";
import LoginForm from "./login-form";
import RegisterForm from "./register-form";
import ResetForm from "./reset-form";
export default function EmailAuthForm() {
const { t } = useTranslation("auth");
const navigate = useNavigate();
const { getUserInfo } = useGlobalStore();
const [type, setType] = useState<"login" | "register" | "reset">("login");
const [loading, startTransition] = useTransition();
const [initialValues, setInitialValues] = useState<{
email?: string;
password?: string;
}>({
email: USER_EMAIL,
password: USER_PASSWORD,
});
const handleFormSubmit = async (params: any) => {
const onLogin = async (token?: string) => {
if (!token) return;
setAuthorization(token);
await getUserInfo();
navigate({ to: getRedirectUrl() });
};
startTransition(async () => {
try {
switch (type) {
case "login": {
const login = await userLogin(params);
toast.success(t("login.success", "Login successful!"));
onLogin(login.data.data?.token);
break;
}
case "register": {
const create = await userRegister(params);
toast.success(t("register.success", "Registration successful!"));
onLogin(create.data.data?.token);
break;
}
case "reset":
await resetPassword(params);
toast.success(t("reset.success", "Password reset successful!"));
setType("login");
break;
}
} catch (_error) {
/* empty */
}
});
};
let UserForm: ReactNode = null;
switch (type) {
case "login":
UserForm = (
<LoginForm
initialValues={initialValues}
loading={loading}
onSubmit={handleFormSubmit}
onSwitchForm={setType}
setInitialValues={setInitialValues}
/>
);
break;
case "register":
UserForm = (
<RegisterForm
initialValues={initialValues}
loading={loading}
onSubmit={handleFormSubmit}
onSwitchForm={setType}
setInitialValues={setInitialValues}
/>
);
break;
case "reset":
UserForm = (
<ResetForm
initialValues={initialValues}
loading={loading}
onSubmit={handleFormSubmit}
onSwitchForm={setType}
setInitialValues={setInitialValues}
/>
);
break;
}
return (
<>
<div className="mb-11 text-center">
<h1 className="mb-3 font-bold text-2xl">
{t(`${type || "check"}.title`)}
</h1>
<div className="font-medium text-muted-foreground">
{t(`${type || "check"}.description`)}
</div>
</div>
{UserForm}
</>
);
}
@@ -0,0 +1,147 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@workspace/ui/components/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from "@workspace/ui/components/form";
import { Input } from "@workspace/ui/components/input";
import { Icon } from "@workspace/ui/composed/icon";
import type { Dispatch, SetStateAction } from "react";
import { useRef } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { useGlobalStore } from "@/stores/global";
import CloudFlareTurnstile, { type TurnstileRef } from "../turnstile";
export default function LoginForm({
loading,
onSubmit,
initialValues,
setInitialValues,
onSwitchForm,
}: {
loading?: boolean;
onSubmit: (data: any) => void;
initialValues: any;
setInitialValues: Dispatch<SetStateAction<any>>;
onSwitchForm: Dispatch<SetStateAction<"register" | "reset" | "login">>;
}) {
const { t } = useTranslation("auth");
const { common } = useGlobalStore();
const { verify } = common;
const formSchema = z.object({
email: z.string().email(t("login.email", "Email")),
password: z.string(),
cf_token:
verify.enable_login_verify && verify.turnstile_site_key
? z.string()
: z.string().optional(),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: initialValues,
});
const turnstile = useRef<TurnstileRef>(null);
const handleSubmit = form.handleSubmit((data) => {
try {
onSubmit(data);
} catch (_error) {
turnstile.current?.reset();
}
});
return (
<>
<Form {...form}>
<form className="grid gap-6" onSubmit={handleSubmit}>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
placeholder={t(
"login.emailPlaceholder",
"Enter your email..."
)}
type="email"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
placeholder={t(
"login.passwordPlaceholder",
"Enter your password..."
)}
type="password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{verify.enable_login_verify && (
<FormField
control={form.control}
name="cf_token"
render={({ field }) => (
<FormItem>
<FormControl>
<CloudFlareTurnstile
id="login"
{...field}
ref={turnstile}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<Button disabled={loading} type="submit">
{loading && <Icon className="animate-spin" icon="mdi:loading" />}
{t("login.title", "Login")}
</Button>
</form>
</Form>
<div className="mt-4 flex w-full justify-between text-sm">
<Button
className="p-0"
onClick={() => onSwitchForm("reset")}
type="button"
variant="link"
>
{t("login.forgotPassword", "Forgot Password?")}
</Button>
<Button
className="p-0"
onClick={() => {
setInitialValues(undefined);
onSwitchForm("register");
}}
variant="link"
>
{t("login.registerAccount", "Register Account")}
</Button>
</div>
</>
);
}
@@ -0,0 +1,251 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@workspace/ui/components/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from "@workspace/ui/components/form";
import { Input } from "@workspace/ui/components/input";
import { Icon } from "@workspace/ui/composed/icon";
import { Markdown } from "@workspace/ui/composed/markdown";
import type { Dispatch, SetStateAction } from "react";
import { useRef } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { useGlobalStore } from "@/stores/global";
import SendCode from "../send-code";
import CloudFlareTurnstile, { type TurnstileRef } from "../turnstile";
export default function RegisterForm({
loading,
onSubmit,
initialValues,
setInitialValues,
onSwitchForm,
}: {
loading?: boolean;
onSubmit: (data: any) => void;
initialValues: any;
setInitialValues: Dispatch<SetStateAction<any>>;
onSwitchForm: Dispatch<SetStateAction<"register" | "reset" | "login">>;
}) {
const { t } = useTranslation("auth");
const { common } = useGlobalStore();
const { verify, auth, invite } = common;
const handleCheckUser = async (email: string) => {
try {
if (!auth.email.enable_domain_suffix) return true;
const domain = email.split("@")[1];
const isValid = auth.email?.domain_suffix_list
.split("\n")
.includes(domain || "");
return isValid;
} catch (error) {
console.log("Error checking user:", error);
return false;
}
};
const formSchema = z
.object({
email: z
.string()
.email(t("register.email", "Email"))
.refine(handleCheckUser, {
message: t("register.whitelist", "Email domain not allowed"),
}),
password: z.string(),
repeat_password: z.string(),
code: auth.email.enable_verify ? z.string() : z.string().nullish(),
invite: invite.forced_invite ? z.string().min(1) : z.string().nullish(),
cf_token:
verify.enable_register_verify && verify.turnstile_site_key
? z.string()
: z.string().nullish(),
})
.superRefine(({ password, repeat_password }, ctx) => {
if (password !== repeat_password) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("register.passwordMismatch", "Passwords do not match"),
path: ["repeat_password"],
});
}
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
...initialValues,
invite: localStorage.getItem("invite") || "",
},
});
const turnstile = useRef<TurnstileRef>(null);
const handleSubmit = form.handleSubmit((data) => {
try {
onSubmit(data);
} catch (_error) {
turnstile.current?.reset();
}
});
return (
<>
{auth.register.stop_register ? (
<Markdown>{t("register.message", "Registration is disabled")}</Markdown>
) : (
<Form {...form}>
<form className="grid gap-6" onSubmit={handleSubmit}>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
placeholder={t(
"register.emailPlaceholder",
"Enter your email..."
)}
type="email"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
placeholder={t(
"register.passwordPlaceholder",
"Enter your password..."
)}
type="password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="repeat_password"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
disabled={loading}
placeholder={t(
"register.repeatPasswordPlaceholder",
"Enter password again..."
)}
type="password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{auth.email.enable_verify && (
<FormField
control={form.control}
name="code"
render={({ field }) => (
<FormItem>
<FormControl>
<div className="flex items-center gap-2">
<Input
disabled={loading}
placeholder={t(
"register.codePlaceholder",
"Enter code..."
)}
type="text"
{...field}
value={field.value as string}
/>
<SendCode
params={{
...form.getValues(),
type: 1,
}}
type="email"
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="invite"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
disabled={loading || !!localStorage.getItem("invite")}
placeholder={t("register.invite", "Invite Code")}
{...field}
value={field.value || ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{verify.enable_register_verify && (
<FormField
control={form.control}
name="cf_token"
render={({ field }) => (
<FormItem>
<FormControl>
<CloudFlareTurnstile
id="register"
{...field}
ref={turnstile}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<Button disabled={loading} type="submit">
{loading && <Icon className="animate-spin" icon="mdi:loading" />}
{t("register.title", "Register")}
</Button>
</form>
</Form>
)}
<div className="mt-4 text-right text-sm">
{t("register.existingAccount", "Already have an account?")}&nbsp;
<Button
className="p-0"
onClick={() => {
setInitialValues(undefined);
onSwitchForm("login");
}}
variant="link"
>
{t("register.switchToLogin", "Login")}
</Button>
</div>
</>
);
}
@@ -0,0 +1,170 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@workspace/ui/components/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from "@workspace/ui/components/form";
import { Input } from "@workspace/ui/components/input";
import { Icon } from "@workspace/ui/composed/icon";
import type { Dispatch, SetStateAction } from "react";
import { useRef } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { useGlobalStore } from "@/stores/global";
import SendCode from "../send-code";
import CloudFlareTurnstile, { type TurnstileRef } from "../turnstile";
export default function ResetForm({
loading,
onSubmit,
initialValues,
setInitialValues,
onSwitchForm,
}: {
loading?: boolean;
onSubmit: (data: any) => void;
initialValues: any;
setInitialValues: Dispatch<SetStateAction<any>>;
onSwitchForm: Dispatch<SetStateAction<"register" | "reset" | "login">>;
}) {
const { t } = useTranslation("auth");
const { common } = useGlobalStore();
const { verify, auth } = common;
const formSchema = z.object({
email: z.string().email(t("reset.email", "Email")),
password: z.string(),
code: auth?.email?.enable_verify ? z.string() : z.string().nullish(),
cf_token:
verify.enable_register_verify && verify.turnstile_site_key
? z.string()
: z.string().nullish(),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: initialValues,
});
const turnstile = useRef<TurnstileRef>(null);
const handleSubmit = form.handleSubmit((data) => {
try {
onSubmit(data);
} catch (_error) {
turnstile.current?.reset();
}
});
return (
<>
<Form {...form}>
<form className="grid gap-6" onSubmit={handleSubmit}>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
placeholder={t(
"reset.emailPlaceholder",
"Enter your email..."
)}
type="email"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="code"
render={({ field }) => (
<FormItem>
<FormControl>
<div className="flex items-center gap-2">
<Input
disabled={loading}
placeholder={t("reset.codePlaceholder", "Enter code...")}
type="text"
{...field}
value={field.value as string}
/>
<SendCode
params={{
...form.getValues(),
type: 2,
}}
type="email"
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
placeholder={t(
"reset.passwordPlaceholder",
"Enter your new password..."
)}
type="password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{verify.enable_reset_password_verify && (
<FormField
control={form.control}
name="cf_token"
render={({ field }) => (
<FormItem>
<FormControl>
<CloudFlareTurnstile
id="reset"
{...field}
ref={turnstile}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<Button disabled={loading} type="submit">
{loading && <Icon className="animate-spin" icon="mdi:loading" />}
{t("reset.title", "Reset Password")}
</Button>
</form>
</Form>
<div className="mt-4 text-right text-sm">
{t("reset.existingAccount", "Remember your password?")}&nbsp;
<Button
className="p-0"
onClick={() => {
setInitialValues(undefined);
onSwitchForm("login");
}}
variant="link"
>
{t("reset.switchToLogin", "Login")}
</Button>
</div>
</>
);
}
+68
View File
@@ -0,0 +1,68 @@
"use client";
import { DotLottieReact } from "@lottiefiles/dotlottie-react";
import { Link, useNavigate } from "@tanstack/react-router";
import { LanguageSwitch } from "@workspace/ui/composed/language-switch";
import { ThemeSwitch } from "@workspace/ui/composed/theme-switch";
import { useEffect } from "react";
import { useGlobalStore } from "@/stores/global";
import EmailAuthForm from "./email/auth-form";
export default function Auth() {
const { common, user } = useGlobalStore();
const { site } = common;
const navigate = useNavigate();
useEffect(() => {
if (user) {
// navigate({ to: "/dashboard" });
}
}, [navigate, user]);
return (
<main className="flex h-full min-h-screen items-center bg-muted/50">
<div className="flex size-full flex-auto flex-col justify-center lg:flex-row">
<div className="flex lg:w-1/2 lg:flex-auto">
<div className="flex w-full flex-col items-center justify-center px-5 py-4 md:px-14 lg:py-14">
<Link className="mb-0 flex flex-col items-center lg:mb-12" to="/">
<img
alt="logo"
height={48}
src={site.site_logo || "/favicon.svg"}
width={48}
/>
<span className="font-semibold text-2xl">{site.site_name}</span>
</Link>
<DotLottieReact
autoplay
className="mx-auto hidden w-full lg:block"
loop
src="./lotties/login.json"
/>
<p className="hidden w-[275px] text-center md:w-1/2 lg:block xl:w-[500px]">
{site.site_desc}
</p>
</div>
</div>
<div className="flex flex-initial justify-center p-8 lg:flex-auto lg:justify-end">
<div className="flex flex-col items-center rounded-2xl md:w-[600px] lg:flex-auto lg:bg-background lg:p-10 lg:shadow">
<div className="flex flex-col items-stretch justify-center md:w-[400px] lg:h-full">
<div className="flex flex-col justify-center pb-14 lg:flex-auto lg:pb-20">
<EmailAuthForm />
</div>
<div className="flex items-center justify-end">
{/* <div className='text-primary flex gap-5 text-sm font-semibold'>
<Link href='/tos'>{t('tos')}</Link>
</div> */}
<div className="flex items-center gap-5">
<LanguageSwitch />
<ThemeSwitch />
</div>
</div>
</div>
</div>
</div>
</div>
</main>
);
}
@@ -0,0 +1,69 @@
import { Button } from "@workspace/ui/components/button";
import {
sendEmailCode,
sendSmsCode,
} from "@workspace/ui/services/common/common";
import { useCountDown } from "ahooks";
import { useState } from "react";
import { useTranslation } from "react-i18next";
interface SendCodeProps {
type: "email" | "phone";
params: {
email?: string;
type?: 1 | 2;
telephone_area_code?: string;
telephone?: string;
};
}
export default function SendCode({ type, params }: SendCodeProps) {
const { t } = useTranslation("auth");
const [targetDate, setTargetDate] = useState<number>();
const [, { seconds }] = useCountDown({
targetDate,
onEnd: () => {
setTargetDate(undefined);
},
});
const getEmailCode = async () => {
if (params.email && params.type) {
await sendEmailCode({
email: params.email,
type: params.type,
});
setTargetDate(Date.now() + 60_000);
}
};
const getPhoneCode = async () => {
if (params.telephone && params.telephone_area_code && params.type) {
await sendSmsCode({
telephone: params.telephone,
telephone_area_code: params.telephone_area_code,
type: params.type,
});
setTargetDate(Date.now() + 60_000);
}
};
const handleSendCode = async () => {
if (type === "email") {
getEmailCode();
} else {
getPhoneCode();
}
};
const disabled =
seconds > 0 ||
(type === "email"
? !params.email
: !(params.telephone && params.telephone_area_code));
return (
<Button disabled={disabled} onClick={handleSendCode} type="button">
{seconds > 0 ? `${seconds}s` : t("get", "Get Code")}
</Button>
);
}
@@ -0,0 +1,63 @@
import { useTheme } from "next-themes";
import { forwardRef, useEffect, useImperativeHandle } from "react";
import { useTranslation } from "react-i18next";
import Turnstile, { useTurnstile } from "react-turnstile";
import { useGlobalStore } from "@/stores/global";
export type TurnstileRef = {
reset: () => void;
};
const CloudFlareTurnstile = forwardRef<
TurnstileRef,
{
id?: string;
value?: null | string;
onChange: (value?: string) => void;
}
>(function CloudFlareTurnstile({ id, value, onChange }, ref) {
const { common } = useGlobalStore();
const { verify } = common;
const { resolvedTheme } = useTheme();
const { i18n } = useTranslation();
const locale = i18n.language;
const turnstile = useTurnstile();
useImperativeHandle(
ref,
() => ({
reset: () => turnstile.reset(),
}),
[turnstile]
);
useEffect(() => {
if (value === "") {
turnstile.reset();
}
}, [turnstile, value]);
return (
verify.turnstile_site_key && (
<Turnstile
fixedSize
id={id}
language={locale.toLowerCase()}
onExpire={() => {
onChange();
turnstile.reset();
}}
onTimeout={() => {
onChange();
turnstile.reset();
}}
onVerify={(token) => onChange(token)}
sitekey={verify.turnstile_site_key}
theme={resolvedTheme as "light" | "dark"}
/>
)
);
});
export default CloudFlareTurnstile;
@@ -0,0 +1,397 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@workspace/ui/components/button";
import {
Form,
FormControl,
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 { Combobox } from "@workspace/ui/composed/combobox";
import { DatePicker } from "@workspace/ui/composed/date-picker";
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
import { Icon } from "@workspace/ui/composed/icon";
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 { z } from "zod";
import { useSubscribe } from "@/stores/subscribe";
const formSchema = z.object({
name: z.string(),
code: z.string().optional(),
count: z.number().optional(),
type: z.number().optional(),
discount: z.number().optional(),
start_time: z.number().optional(),
expire_time: z.number().optional(),
subscribe: z.array(z.number()).nullish(),
user_limit: z.number().optional(),
});
interface CouponFormProps<T> {
onSubmit: (data: T) => Promise<boolean> | boolean;
initialValues?: T;
loading?: boolean;
trigger: string;
title: string;
}
export default function CouponForm<T extends Record<string, any>>({
onSubmit,
initialValues,
loading,
trigger,
title,
}: CouponFormProps<T>) {
const { t } = useTranslation("coupon");
const [open, setOpen] = useState(false);
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: {
type: 1,
...initialValues,
} as any,
});
useEffect(() => {
form?.reset(initialValues);
}, [form, initialValues]);
async function handleSubmit(data: { [x: string]: any }) {
const bool = await onSubmit(data as T);
if (bool) setOpen(false);
}
const type = form.watch("type");
const { subscribes } = useSubscribe();
return (
<Sheet onOpenChange={setOpen} open={open}>
<SheetTrigger asChild>
<Button
onClick={() => {
form.reset();
setOpen(true);
}}
>
{trigger}
</Button>
</SheetTrigger>
<SheetContent className="w-[500px] max-w-full md:max-w-screen-md">
<SheetHeader>
<SheetTitle>{title}</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100vh-48px-36px-36px-env(safe-area-inset-top))]">
<Form {...form}>
<form
className="space-y-4 px-6 pt-4"
onSubmit={form.handleSubmit(handleSubmit)}
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("form.name", "Name")}</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={(value) => {
form.setValue(field.name, value);
}}
placeholder={t(
"form.enterCouponName",
"Enter Coupon Name"
)}
value={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="code"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("form.customCouponCode", "Custom Coupon Code")}
</FormLabel>
<FormControl>
<EnhancedInput
placeholder={t(
"form.customCouponCodePlaceholder",
"Custom Coupon Code (leave blank for auto-generation)"
)}
{...field}
onValueChange={(value) => {
form.setValue(field.name, value);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>{t("form.type", "Coupon Type")}</FormLabel>
<FormControl>
<RadioGroup
className="flex gap-2"
defaultValue={String(field.value)}
onValueChange={(value) => {
form.setValue(field.name, Number(value));
form.setValue("discount", "");
}}
>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="1" />
</FormControl>
<FormLabel className="font-normal">
{t(
"form.percentageDiscount",
"Percentage Discount"
)}
</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="2" />
</FormControl>
<FormLabel className="font-normal">
{t("form.amountDiscount", "Amount Discount")}
</FormLabel>
</FormItem>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{type === 1 && (
<FormField
control={form.control}
name="discount"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("form.percentageDiscount", "Percentage Discount")}
</FormLabel>
<FormControl>
<EnhancedInput
max={100}
min={1}
onValueChange={(value) => {
form.setValue(field.name, value);
}}
placeholder={t("form.enterValue", "Enter Value")}
suffix="%"
type="number"
value={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{type === 2 && (
<FormField
control={form.control}
name="discount"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("form.amountDiscount", "Amount Discount")}
</FormLabel>
<FormControl>
<EnhancedInput
formatInput={(value) =>
unitConversion("centsToDollars", value)
}
formatOutput={(value) =>
unitConversion("dollarsToCents", value)
}
onValueChange={(value) => {
form.setValue(field.name, value);
}}
placeholder={t("form.enterValue", "Enter Value")}
type="number"
value={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="subscribe"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("form.specifiedServer", "Specified Subscription")}
</FormLabel>
<FormControl>
<Combobox<number, true>
multiple
onChange={(value) => {
form.setValue(field.name, value);
}}
options={subscribes?.map((item) => ({
value: item.id!,
label: item.name!,
}))}
placeholder={t(
"form.selectServer",
"Select Subscription"
)}
value={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="start_time"
render={({ field }) => (
<FormItem>
<FormLabel>{t("form.startTime", "Start Time")}</FormLabel>
<FormControl>
<DatePicker
disabled={(date: Date) =>
date < new Date(Date.now() - 24 * 60 * 60 * 1000)
}
onChange={(value: number | undefined) => {
form.setValue(field.name, value);
}}
placeholder={t("form.enterValue", "Enter Value")}
value={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="expire_time"
render={({ field }) => (
<FormItem>
<FormLabel>{t("form.expireTime", "Expire Time")}</FormLabel>
<FormControl>
<DatePicker
onChange={(value: number | undefined) => {
form.setValue(field.name, value);
}}
placeholder={t("form.enterValue", "Enter Value")}
value={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="count"
render={({ field }) => (
<FormItem>
<FormLabel>{t("form.count", "Max Usage Count")}</FormLabel>
<FormControl>
<EnhancedInput
min={0}
placeholder={t(
"form.countPlaceholder",
"Max Usage Count (leave blank for no limit)"
)}
step={1}
type="number"
{...field}
onValueChange={(value) => {
form.setValue(field.name, value);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="user_limit"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("form.userLimit", "Max Usage Count per User")}
</FormLabel>
<FormControl>
<EnhancedInput
min={0}
placeholder={t(
"form.userLimitPlaceholder",
"Max Usage Count per User (leave blank for no limit)"
)}
step={1}
type="number"
{...field}
onValueChange={(value) => {
form.setValue(field.name, value);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<SheetFooter className="flex-row justify-end gap-2 pt-3">
<Button
disabled={loading}
onClick={() => {
setOpen(false);
}}
variant="outline"
>
{t("form.cancel", "Cancel")}
</Button>
<Button disabled={loading} onClick={form.handleSubmit(handleSubmit)}>
{loading && (
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
)}{" "}
{t("form.confirm", "Confirm")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
+240
View File
@@ -0,0 +1,240 @@
import { Badge } from "@workspace/ui/components/badge";
import { Button } from "@workspace/ui/components/button";
import { Switch } from "@workspace/ui/components/switch";
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
import {
ProTable,
type ProTableActions,
} from "@workspace/ui/composed/pro-table/pro-table";
import {
batchDeleteCoupon,
createCoupon,
deleteCoupon,
getCouponList,
updateCoupon,
} from "@workspace/ui/services/admin/coupon";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Display } from "@/components/display";
import { useSubscribe } from "@/stores/subscribe";
import { formatDate } from "@/utils/common";
import CouponForm from "./coupon-form";
export default function Coupon() {
const { t } = useTranslation("coupon");
const [loading, setLoading] = useState(false);
const { subscribes } = useSubscribe();
const ref = useRef<ProTableActions>(null);
return (
<ProTable<API.Coupon, { group_id: number; query: string }>
action={ref}
actions={{
render: (row) => [
<CouponForm<API.UpdateCouponRequest>
initialValues={row}
key="edit"
loading={loading}
onSubmit={async (values) => {
setLoading(true);
try {
await updateCoupon({ ...row, ...values });
toast.success(t("updateSuccess", "Update Success"));
ref.current?.refresh();
setLoading(false);
return true;
} catch (_error) {
setLoading(false);
return false;
}
}}
title={t("editCoupon", "Edit Coupon")}
trigger={t("edit", "Edit")}
/>,
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description={t(
"deleteWarning",
"Once deleted, data cannot be recovered. Please proceed with caution."
)}
key="delete"
onConfirm={async () => {
await deleteCoupon({ id: row.id });
toast.success(t("deleteSuccess", "Delete Success"));
ref.current?.refresh();
}}
title={t("confirmDelete", "Are you sure you want to delete?")}
trigger={
<Button variant="destructive">{t("delete", "Delete")}</Button>
}
/>,
],
batchRender: (rows) => [
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description={t(
"deleteWarning",
"Once deleted, data cannot be recovered. Please proceed with caution."
)}
key="delete"
onConfirm={async () => {
await batchDeleteCoupon({ ids: rows.map((item) => item.id) });
toast.success(t("deleteSuccess", "Delete Success"));
ref.current?.reset();
}}
title={t("confirmDelete", "Are you sure you want to delete?")}
trigger={
<Button variant="destructive">{t("delete", "Delete")}</Button>
}
/>,
],
}}
columns={[
{
accessorKey: "enable",
header: t("enable", "Enable"),
cell: ({ row }) => (
<Switch
defaultChecked={row.getValue("enable")}
onCheckedChange={async (checked) => {
await updateCoupon({
...row.original,
enable: checked,
} as API.UpdateCouponRequest);
ref.current?.refresh();
}}
/>
),
},
{
accessorKey: "name",
header: t("name", "Name"),
},
{
accessorKey: "code",
header: t("code", "Code"),
},
{
accessorKey: "type",
header: t("type", "Type"),
cell: ({ row }) => (
<Badge
variant={row.getValue("type") === 1 ? "default" : "secondary"}
>
{row.getValue("type") === 1
? t("percentage", "Percentage")
: t("amount", "Amount")}
</Badge>
),
},
{
accessorKey: "discount",
header: t("discount", "Discount"),
cell: ({ row }) => (
<Badge
variant={row.getValue("type") === 1 ? "default" : "secondary"}
>
{row.getValue("type") === 1 ? (
`${row.original.discount} %`
) : (
<Display type="currency" value={row.original.discount} />
)}
</Badge>
),
},
{
accessorKey: "count",
header: t("count", "Count"),
cell: ({ row }) => (
<div className="flex flex-col">
<span>
{t("count", "Count")}:{" "}
{row.original.count === 0
? t("unlimited", "Unlimited")
: row.original.count}
</span>
<span>
{t("remainingTimes", "Remaining")}:{" "}
{row.original.count === 0
? t("unlimited", "Unlimited")
: row.original.count - row.original.used_count}
</span>
<span>
{t("usedTimes", "Usage Times")}: {row.original.used_count}
</span>
</div>
),
},
{
accessorKey: "expire",
header: t("validityPeriod", "Validity Period"),
cell: ({ row }) => {
const { start_time, expire_time } = row.original;
if (start_time) {
return expire_time ? (
<>
{formatDate(start_time)} - {formatDate(expire_time)}
</>
) : start_time ? (
formatDate(start_time)
) : (
"--"
);
}
return "--";
},
},
]}
header={{
toolbar: (
<CouponForm<API.CreateCouponRequest>
loading={loading}
onSubmit={async (values) => {
setLoading(true);
try {
await createCoupon({
...values,
enable: false,
});
toast.success(t("createSuccess", "Create Success"));
ref.current?.refresh();
setLoading(false);
return true;
} catch (_error) {
setLoading(false);
return false;
}
}}
title={t("createCoupon", "Create Coupon")}
trigger={t("create", "Create")}
/>
),
}}
params={[
{
key: "subscribe",
placeholder: t("subscribe", "Subscribe"),
options: subscribes?.map((item) => ({
label: item.name!,
value: String(item.id),
})),
},
{
key: "search",
},
]}
request={async (pagination, filters) => {
const { data } = await getCouponList({
...pagination,
...filters,
});
return {
list: data.data?.list || [],
total: data.data?.total || 0,
};
}}
/>
);
}
@@ -0,0 +1,109 @@
import { useQuery } from "@tanstack/react-query";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@workspace/ui/components/avatar";
import {
Card,
CardDescription,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import { useTranslation } from "react-i18next";
interface BillingProps {
type: "dashboard" | "payment";
}
interface ItemType {
logo: string;
title: string;
description: string;
expiryDate: string;
href: string;
}
async function getBillingURL() {
try {
const response = await fetch(
"https://api.github.com/repos/perfect-panel/ppanel-assets/commits"
);
const json = await response.json();
const version = json[0]?.sha || "latest";
const url = new URL(
"https://cdn.jsdmirror.com/gh/perfect-panel/ppanel-assets"
);
url.pathname += `@${version}/billing/index.json`;
return url.toString();
} catch (_error) {
return "https://cdn.jsdmirror.com/gh/perfect-panel/ppanel-assets/billing/index.json";
}
}
export default function Billing({ type }: BillingProps) {
const { t } = useTranslation("dashboard");
const { data: list } = useQuery({
queryKey: ["billing", type],
queryFn: async () => {
const url = await getBillingURL();
const response = await fetch(url, {
headers: {
Accept: "application/json",
},
});
const data = await response.json();
const now = Date.now();
return Array.isArray(data[type])
? data[type].filter((item: { expiryDate: string }) => {
const expiryDate = Date.parse(item.expiryDate);
return !Number.isNaN(expiryDate) && expiryDate > now;
})
: [];
},
initialData: [],
});
if (!list?.length) return null;
return (
<>
<h1 className="text mt-2 font-bold">
<span>{t("billing.title", "Sponsor")}</span>
<span className="ml-2 text-muted-foreground text-xs">
{t(
"billing.description",
"Sponsoring helps PPanel to continue releasing updates!"
)}
</span>
</h1>
<div className="grid gap-3 md:grid-cols-3 lg:grid-cols-6">
{list.map((item: ItemType, index: number) => (
<a
href={item.href}
key={index}
rel="noopener noreferrer"
target="_blank"
>
<Card className="h-full cursor-pointer">
<CardHeader className="flex flex-row gap-2 p-3">
<Avatar>
<AvatarImage src={item.logo} />
<AvatarFallback>{item.title}</AvatarFallback>
</Avatar>
<div>
<CardTitle>{item.title}</CardTitle>
<CardDescription className="mt-2">
{item.description}
</CardDescription>
</div>
</CardHeader>
</Card>
</a>
))}
</div>
</>
);
}
@@ -0,0 +1,457 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from "@workspace/ui/components/chart";
import { Empty } from "@workspace/ui/components/empty";
import { Separator } from "@workspace/ui/components/separator";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@workspace/ui/components/tabs";
import { queryRevenueStatistics } from "@workspace/ui/services/admin/console";
import { unitConversion } from "@workspace/ui/utils/unit-conversions";
import { useTranslation } from "react-i18next";
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
Label,
Pie,
PieChart,
XAxis,
} from "recharts";
import { Display } from "@/components/display";
export function RevenueStatisticsCard() {
const { t, i18n } = useTranslation("dashboard");
const locale = i18n.language;
const IncomeStatisticsConfig = {
new_purchase: {
label: t("newPurchase", "New Purchase"),
color: "var(--color-chart-1)",
},
repurchase: {
label: t("repurchase", "Repurchase"),
color: "var(--color-chart-2)",
},
total: {
label: t("totalIncome", "Total Income"),
color: "var(--color-chart-3)",
},
};
const { data: RevenueStatistics } = useQuery({
queryKey: ["queryRevenueStatistics"],
queryFn: async () => {
const { data } = await queryRevenueStatistics();
return data.data;
},
});
return (
<Tabs defaultValue="today">
<Card className="h-full pb-0">
<CardHeader className="!flex-row flex items-center justify-between">
<CardTitle>{t("revenueTitle", "Revenue Statistics")}</CardTitle>
<TabsList>
<TabsTrigger value="today">{t("today", "Today")}</TabsTrigger>
<TabsTrigger value="month">{t("month", "Month")}</TabsTrigger>
<TabsTrigger value="total">{t("total", "Total")}</TabsTrigger>
</TabsList>
</CardHeader>
<TabsContent className="h-full" value="today">
<CardContent className="h-80">
{RevenueStatistics?.today.new_order_amount ||
RevenueStatistics?.today.renewal_order_amount ? (
<ChartContainer
className="mx-auto max-h-80"
config={IncomeStatisticsConfig}
>
<PieChart>
<ChartLegend content={<ChartLegendContent />} />
<ChartTooltip
content={<ChartTooltipContent hideLabel />}
cursor={false}
/>
<Pie
data={[
{
type: "new_purchase",
value: unitConversion(
"centsToDollars",
RevenueStatistics?.today.new_order_amount
),
fill: "var(--color-new_purchase)",
},
{
type: "repurchase",
value: unitConversion(
"centsToDollars",
RevenueStatistics?.today.renewal_order_amount
),
fill: "var(--color-repurchase)",
},
]}
dataKey="value"
innerRadius={50}
nameKey="type"
strokeWidth={5}
>
<Label
content={({ viewBox }) => {
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
return (
<text
dominantBaseline="middle"
textAnchor="middle"
x={viewBox.cx}
y={viewBox.cy}
>
<tspan
className="fill-foreground font-bold text-2xl"
x={viewBox.cx}
y={viewBox.cy}
>
{unitConversion(
"centsToDollars",
RevenueStatistics?.today.amount_total
)}
</tspan>
</text>
);
}
}}
/>
</Pie>
</PieChart>
</ChartContainer>
) : (
<div className="flex h-full items-center justify-center">
<Empty />
</div>
)}
</CardContent>
<CardFooter className="!py-5 flex h-20 flex-row border-t">
<div className="flex w-full items-center gap-2">
<div className="grid flex-1 auto-rows-min gap-0.5">
<div className="text-muted-foreground text-xs">
{t("totalIncome", "Total Income")}
</div>
<div className="font-bold text-xl tabular-nums leading-none">
<Display
type="currency"
value={RevenueStatistics?.today.amount_total}
/>
</div>
</div>
<Separator className="!h-10 mx-2 w-px" orientation="vertical" />
<div className="grid flex-1 auto-rows-min gap-0.5">
<div className="text-muted-foreground text-xs">
{IncomeStatisticsConfig.new_purchase.label}
</div>
<div className="font-bold text-xl tabular-nums leading-none">
<Display
type="currency"
value={RevenueStatistics?.today.new_order_amount}
/>
</div>
</div>
<Separator className="!h-10 mx-2 w-px" orientation="vertical" />
<div className="grid flex-1 auto-rows-min gap-0.5">
<div className="text-muted-foreground text-xs">
{IncomeStatisticsConfig.repurchase.label}
</div>
<div className="font-bold text-xl tabular-nums leading-none">
<Display
type="currency"
value={RevenueStatistics?.today.renewal_order_amount}
/>
</div>
</div>
</div>
</CardFooter>
</TabsContent>
<TabsContent className="h-full" value="month">
<CardContent className="h-80">
{RevenueStatistics?.monthly.list &&
RevenueStatistics?.monthly.list.length > 0 ? (
<ChartContainer
className="max-h-80 w-full"
config={IncomeStatisticsConfig}
>
<BarChart
accessibilityLayer
data={
RevenueStatistics?.monthly.list?.map((item) => ({
date: item.date,
new_purchase: unitConversion(
"centsToDollars",
item.new_order_amount
),
repurchase: unitConversion(
"centsToDollars",
item.renewal_order_amount
),
total: unitConversion(
"centsToDollars",
item.new_order_amount + item.renewal_order_amount
),
})) || []
}
>
<CartesianGrid vertical={false} />
<XAxis
axisLine={false}
dataKey="date"
tickFormatter={(value) => {
const [year, month, day] = value.split("-");
return new Date(year, month - 1, day).toLocaleDateString(
locale,
{
month: "short",
day: "numeric",
}
);
}}
tickLine={false}
tickMargin={10}
/>
<Bar
dataKey="new_purchase"
fill="var(--color-new_purchase)"
radius={[0, 0, 4, 4]}
stackId="a"
/>
<Bar
dataKey="repurchase"
fill="var(--color-repurchase)"
radius={[4, 4, 0, 0]}
stackId="a"
/>
<ChartTooltip
content={
<ChartTooltipContent
formatter={(value, name, item, index) => (
<>
<div
className="h-2.5 w-2.5 shrink-0 rounded-[2px] bg-[--color-bg]"
style={
{
"--color-bg": `var(--color-${name})`,
} as React.CSSProperties
}
/>
{IncomeStatisticsConfig[
name as keyof typeof IncomeStatisticsConfig
]?.label || name}
<div className="ml-auto flex items-baseline gap-0.5 font-medium font-mono text-foreground tabular-nums">
{value}
</div>
{index === 1 && (
<div className="flex basis-full items-center border-t pt-1.5 font-medium text-foreground text-xs">
{t("totalIncome", "Total Income")}
<div className="ml-auto flex items-baseline gap-0.5 font-medium font-mono text-foreground tabular-nums">
{item.payload.total}
</div>
</div>
)}
</>
)}
/>
}
cursor={false}
/>
<ChartLegend content={<ChartLegendContent />} />
</BarChart>
</ChartContainer>
) : (
<div className="flex h-full items-center justify-center">
<Empty />
</div>
)}
</CardContent>
<CardFooter className="!py-5 flex h-20 flex-row border-t">
<div className="flex w-full items-center gap-2">
<div className="grid flex-1 auto-rows-min gap-0.5">
<div className="text-muted-foreground text-xs">
{t("totalIncome", "Total Income")}
</div>
<div className="font-bold text-xl tabular-nums leading-none">
<Display
type="currency"
value={RevenueStatistics?.monthly.amount_total}
/>
</div>
</div>
<Separator className="!h-10 mx-2 w-px" orientation="vertical" />
<div className="grid flex-1 auto-rows-min gap-0.5">
<div className="text-muted-foreground text-xs">
{IncomeStatisticsConfig.new_purchase.label}
</div>
<div className="font-bold text-xl tabular-nums leading-none">
<Display
type="currency"
value={RevenueStatistics?.monthly.new_order_amount}
/>
</div>
</div>
<Separator className="!h-10 mx-2 w-px" orientation="vertical" />
<div className="grid flex-1 auto-rows-min gap-0.5">
<div className="text-muted-foreground text-xs">
{IncomeStatisticsConfig.repurchase.label}
</div>
<div className="font-bold text-xl tabular-nums leading-none">
<Display
type="currency"
value={RevenueStatistics?.monthly.renewal_order_amount}
/>
</div>
</div>
</div>
</CardFooter>
</TabsContent>
<TabsContent className="h-full" value="total">
<CardContent className="h-80">
{RevenueStatistics?.all.list &&
RevenueStatistics?.all.list.length > 0 ? (
<ChartContainer
className="max-h-80 w-full"
config={IncomeStatisticsConfig}
>
<AreaChart
accessibilityLayer
data={
RevenueStatistics?.all.list?.map((item) => ({
date: item.date,
new_purchase: unitConversion(
"centsToDollars",
item.new_order_amount
),
repurchase: unitConversion(
"centsToDollars",
item.renewal_order_amount
),
total: unitConversion(
"centsToDollars",
item.new_order_amount + item.renewal_order_amount
),
})) || []
}
margin={{
left: 12,
right: 12,
}}
>
<CartesianGrid vertical={false} />
<XAxis
axisLine={false}
dataKey="date"
tickFormatter={(value) => {
const [year, month] = value.split("-");
return new Date(year, month - 1).toLocaleDateString(
locale,
{
month: "short",
}
);
}}
tickLine={false}
/>
<ChartTooltip
content={
<ChartTooltipContent
formatter={(value, name, item, index) => (
<>
<div
className="h-2.5 w-2.5 shrink-0 rounded-[2px] bg-[--color-bg]"
style={
{
"--color-bg": `var(--color-${name})`,
} as React.CSSProperties
}
/>
{IncomeStatisticsConfig[
name as keyof typeof IncomeStatisticsConfig
]?.label || name}
<div className="ml-auto flex items-baseline gap-0.5 font-medium font-mono text-foreground tabular-nums">
{value}
</div>
{index === 1 && (
<div className="flex basis-full items-center border-t pt-1.5 font-medium text-foreground text-xs">
{t("totalIncome", "Total Income")}
<div className="ml-auto flex items-baseline gap-0.5 font-medium font-mono text-foreground tabular-nums">
{item.payload.total}
</div>
</div>
)}
</>
)}
/>
}
cursor={false}
/>
<Area
dataKey="new_purchase"
fill="var(--color-new_purchase)"
fillOpacity={0.4}
stackId="a"
stroke="var(--color-new_purchase)"
type="natural"
/>
<Area
dataKey="repurchase"
fill="var(--color-repurchase)"
fillOpacity={0.4}
stackId="a"
stroke="var(--color-repurchase)"
type="natural"
/>
<ChartLegend content={<ChartLegendContent />} />
</AreaChart>
</ChartContainer>
) : (
<div className="flex h-full items-center justify-center">
<Empty />
</div>
)}
</CardContent>
<CardFooter className="!py-5 flex h-20 flex-row border-t">
<div className="flex w-full items-center gap-2">
<div className="grid flex-1 auto-rows-min gap-0.5">
<div className="text-muted-foreground text-xs">
{t("totalIncome", "Total Income")}
</div>
<div className="font-bold text-xl tabular-nums leading-none">
<Display
type="currency"
value={RevenueStatistics?.all.amount_total}
/>
</div>
</div>
</div>
</CardFooter>
</TabsContent>
</Card>
</Tabs>
);
}
@@ -0,0 +1,311 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@workspace/ui/components/chart";
import { Empty } from "@workspace/ui/components/empty";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@workspace/ui/components/select";
import { Separator } from "@workspace/ui/components/separator";
import { Tabs, TabsList, TabsTrigger } from "@workspace/ui/components/tabs";
import { Icon } from "@workspace/ui/composed/icon";
import {
queryServerTotalData,
queryTicketWaitReply,
} from "@workspace/ui/services/admin/console";
import { formatBytes } from "@workspace/ui/utils/formatting";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
Bar,
BarChart,
CartesianGrid,
LabelList,
XAxis,
YAxis,
} from "recharts";
import { UserSubscribeDetail } from "@/sections/user/user-detail";
import { RevenueStatisticsCard } from "./revenue-statistics-card";
import SystemVersionCard from "./system-version-card";
import { UserStatisticsCard } from "./user-statistics-card";
export default function Statistics() {
const { t } = useTranslation("dashboard");
const { data: TicketTotal } = useQuery({
queryKey: ["queryTicketWaitReply"],
queryFn: async () => {
const { data } = await queryTicketWaitReply();
return data.data?.count;
},
});
const { data: ServerTotal } = useQuery({
queryKey: ["queryServerTotalData"],
queryFn: async () => {
const { data } = await queryServerTotalData();
return data.data;
},
});
const [dataType, setDataType] = useState<string | "nodes" | "users">("nodes");
const [timeFrame, setTimeFrame] = useState<string | "today" | "yesterday">(
"today"
);
const trafficData = {
nodes: {
today:
ServerTotal?.server_traffic_ranking_today?.map((item) => ({
name: item.name,
traffic: item.download + item.upload,
})) || [],
yesterday:
ServerTotal?.server_traffic_ranking_yesterday?.map((item) => ({
name: item.name,
traffic: item.download + item.upload,
})) || [],
},
users: {
today:
ServerTotal?.user_traffic_ranking_today?.map((item) => ({
name: item.sid,
traffic: item.download + item.upload,
})) || [],
yesterday:
ServerTotal?.user_traffic_ranking_yesterday?.map((item) => ({
name: item.sid,
traffic: item.download + item.upload,
})) || [],
},
};
const currentData =
trafficData[dataType as "nodes" | "users"][
timeFrame as "today" | "yesterday"
];
return (
<>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{[
{
title: t("onlineUsersCount", "Online Users"),
value: ServerTotal?.online_users || 0,
subtitle: t("currentlyOnline", "Currently Online"),
icon: "uil:users-alt",
href: "/dashboard/user",
color: "text-blue-600 dark:text-blue-400",
iconBg: "bg-blue-100 dark:bg-blue-900/30",
},
{
title: t("todayTraffic", "Today Traffic"),
value: formatBytes(
(ServerTotal?.today_upload || 0) +
(ServerTotal?.today_download || 0)
),
subtitle: `${formatBytes(ServerTotal?.today_upload || 0)}${formatBytes(ServerTotal?.today_download || 0)}`,
icon: "uil:exchange-alt",
color: "text-purple-600 dark:text-purple-400",
iconBg: "bg-purple-100 dark:bg-purple-900/30",
},
{
title: t("monthTraffic", "Month Traffic"),
value: formatBytes(
(ServerTotal?.monthly_upload || 0) +
(ServerTotal?.monthly_download || 0)
),
subtitle: `${formatBytes(ServerTotal?.monthly_upload || 0)}${formatBytes(ServerTotal?.monthly_download || 0)}`,
icon: "uil:cloud-data-connection",
color: "text-orange-600 dark:text-orange-400",
iconBg: "bg-orange-100 dark:bg-orange-900/30",
},
{
title: t("totalServers", "Total Servers"),
value:
(ServerTotal?.online_servers || 0) +
(ServerTotal?.offline_servers || 0),
subtitle: `${t("online", "Online")} ${ServerTotal?.online_servers || 0} ${t("offline", "Offline")} ${ServerTotal?.offline_servers || 0}`,
icon: "uil:server-network",
href: "/dashboard/servers",
color: "text-green-600 dark:text-green-400",
iconBg: "bg-green-100 dark:bg-green-900/30",
},
{
title: t("pendingTickets", "Pending Tickets"),
value: TicketTotal || 0,
subtitle: t("pending", "Pending"),
icon: "uil:clipboard-notes",
href: "/dashboard/ticket",
color: "text-red-600 dark:text-red-400",
iconBg: "bg-red-100 dark:bg-red-900/30",
},
].map((item, index) => (
<Link
className={item.href ? "" : "pointer-events-none"}
key={index}
to={item.href || "#"}
>
<Card className={`group ${item.href ? "cursor-pointer" : ""}`}>
<CardContent>
<div className="flex items-center justify-between">
<div className="flex-1">
<p className="mb-2 font-medium text-muted-foreground text-sm">
{item.title}
</p>
<div className={`mb-1 font-bold text-2xl ${item.color}`}>
{item.value}
</div>
<div className="h-4 text-muted-foreground text-xs">
{item.subtitle}
</div>
</div>
<div
className={`rounded-full p-3 ${item.iconBg} transition-transform duration-300 group-hover:scale-110`}
>
<Icon
className={`h-6 w-6 ${item.color}`}
icon={item.icon}
/>
</div>
</div>
</CardContent>
</Card>
</Link>
))}
<SystemVersionCard />
</div>
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
<RevenueStatisticsCard />
<UserStatisticsCard />
<Card>
<CardHeader className="!flex-row flex items-center justify-between">
<CardTitle>{t("trafficRank", "Traffic Rank")}</CardTitle>
<Tabs onValueChange={setTimeFrame} value={timeFrame}>
<TabsList>
<TabsTrigger value="today">{t("today", "Today")}</TabsTrigger>
<TabsTrigger value="yesterday">
{t("yesterday", "Yesterday")}
</TabsTrigger>
</TabsList>
</Tabs>
</CardHeader>
<CardContent className="h-80">
<div className="mb-6 flex items-center justify-between">
<h4 className="font-semibold">
{dataType === "nodes"
? t("nodeTraffic", "Node Traffic")
: t("userTraffic", "User Traffic")}
</h4>
<Select defaultValue="nodes" onValueChange={setDataType}>
<SelectTrigger className="w-28">
<SelectValue
placeholder={t("selectTypePlaceholder", "Select Type")}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="nodes">{t("nodes", "Nodes")}</SelectItem>
<SelectItem value="users">{t("users", "Users")}</SelectItem>
</SelectContent>
</Select>
</div>
{currentData.length > 0 ? (
<ChartContainer
className="max-h-80"
config={{
traffic: {
label: t("traffic", "Traffic"),
color: "var(--primary)",
},
type: {
label: t("type", "Type"),
color: "var(--muted-foreground)",
},
label: {
color: "var(--foreground)",
},
}}
>
<BarChart data={currentData} height={400} layout="vertical">
<CartesianGrid strokeDasharray="3 3" />
<XAxis
axisLine={false}
tickFormatter={(value) => formatBytes(value || 0)}
tickLine={false}
type="number"
/>
<YAxis
axisLine={false}
dataKey="name"
interval={0}
tickFormatter={(_value, index) => String(index + 1)}
tickLine={false}
tickMargin={0}
type="category"
width={15}
/>
<ChartTooltip
content={
<ChartTooltipContent
formatter={(value) => formatBytes(Number(value) || 0)}
label={true}
labelFormatter={(label, [payload]) =>
dataType === "nodes" ? (
`${t("nodes", "Nodes")}: ${label}`
) : (
<>
<div className="w-80">
<UserSubscribeDetail
enabled={true}
id={payload?.payload.name}
/>
</div>
<Separator className="my-2" />
<div>{`${t("users", "Users")}: ${label}`}</div>
</>
)
}
/>
}
trigger="hover"
/>
<Bar
dataKey="traffic"
fill="var(--primary)"
radius={[0, 4, 4, 0]}
>
<LabelList
className="fill-[var(--foreground)]"
dataKey="name"
fontSize={12}
offset={8}
position="insideLeft"
/>
</Bar>
</BarChart>
</ChartContainer>
) : (
<div className="flex h-full items-center justify-center">
<Empty />
</div>
)}
</CardContent>
</Card>
</div>
</>
);
}
@@ -0,0 +1,119 @@
import { useQuery } from "@tanstack/react-query";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@workspace/ui/components/accordion";
import { Button } from "@workspace/ui/components/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@workspace/ui/components/dialog";
import { ScrollArea } from "@workspace/ui/components/scroll-area";
import { Icon } from "@workspace/ui/composed/icon";
import { getSystemLog } from "@workspace/ui/services/admin/tool";
import { useState } from "react";
import { useTranslation } from "react-i18next";
interface SystemLogsDialogProps {
trigger?: React.ReactNode;
variant?: "default" | "outline" | "ghost" | "secondary";
size?: "sm" | "default" | "lg";
}
export default function SystemLogsDialog({
trigger,
variant = "outline",
size = "sm",
}: SystemLogsDialogProps) {
const { t } = useTranslation("tool");
const [open, setOpen] = useState(false);
const {
data: logs,
refetch,
isLoading,
} = useQuery({
queryKey: ["getSystemLog"],
queryFn: async () => {
const { data } = await getSystemLog();
return data.data?.list || [];
},
enabled: open,
});
const defaultTrigger = (
<Button size={size} variant={variant}>
{t("systemLogs", "System Logs")}
</Button>
);
return (
<Dialog onOpenChange={setOpen} open={open}>
<DialogTrigger asChild>{trigger || defaultTrigger}</DialogTrigger>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle>{t("systemLogs", "System Logs")}</DialogTitle>
</DialogHeader>
<ScrollArea className="h-[60vh] max-h-[80vh] min-h-[400px] w-full rounded-lg border bg-muted/30 p-1">
{isLoading ? (
<div className="flex h-full items-center justify-center">
<Icon
className="h-8 w-8 animate-spin text-primary"
icon="uil:loading"
/>
</div>
) : (
<Accordion className="w-full" collapsible type="single">
{logs?.map((log: any, index: number) => (
<AccordionItem
className="px-4"
key={index}
value={`item-${index}`}
>
<AccordionTrigger className="hover:no-underline">
<div className="flex w-full flex-col items-start space-y-2 sm:flex-row sm:items-center sm:space-x-4 sm:space-y-0">
<span className="font-medium text-xs sm:text-sm">
{log.timestamp}
</span>
</div>
</AccordionTrigger>
<AccordionContent className="px-2">
{Object.entries(log).map(([key, value]) => (
<div
className="grid grid-cols-1 gap-2 text-xs sm:grid-cols-2 sm:text-sm"
key={key}
>
<span className="font-medium">{key}:</span>
<span className="break-all">{value as string}</span>
</div>
))}
</AccordionContent>
</AccordionItem>
))}
</Accordion>
)}
</ScrollArea>
<DialogFooter>
<Button
onClick={() => {
refetch();
}}
variant="outline"
>
<Icon
className={`h-5 w-5 ${isLoading ? "animate-spin" : ""}`}
icon="uil:refresh"
/>
<span>{t("refreshLogs", "Refresh Logs")}</span>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,258 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@workspace/ui/components/alert-dialog";
import { Badge } from "@workspace/ui/components/badge";
import { Button } from "@workspace/ui/components/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import { Icon } from "@workspace/ui/composed/icon";
import { getVersion, restartSystem } from "@workspace/ui/services/admin/tool";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { formatDate } from "@/utils/common";
import packageJson from "../../../../../../package.json";
import SystemLogsDialog from "./system-logs-dialog";
export default function SystemVersionCard() {
const { t } = useTranslation("tool");
const [openRestart, setOpenRestart] = useState(false);
const [isRestarting, setIsRestarting] = useState(false);
const { data: versionInfo } = useQuery({
queryKey: ["getVersionInfo"],
queryFn: async () => {
try {
const [webResponse, serverResponse, systemResponse] = await Promise.all(
[
fetch(
"https://data.jsdelivr.com/v1/packages/gh/perfect-panel/ppanel-web/resolved?specifier=latest"
),
fetch(
"https://data.jsdelivr.com/v1/packages/gh/perfect-panel/server/resolved?specifier=latest"
),
getVersion(),
]
);
const webData = webResponse.ok ? await webResponse.json() : null;
const serverData = serverResponse.ok
? await serverResponse.json()
: null;
const systemData = systemResponse.data.data;
const rawVersion = (systemData?.version || "")
.replace(" Develop", "")
.trim();
const timeMatch = rawVersion.match(/\(([^)]+)\)/);
const timestamp = timeMatch ? timeMatch[1] : "";
const versionWithoutTime = rawVersion.replace(/\([^)]*\)/, "").trim();
const isDevelopment = !/^[Vv]?\d+\.\d+\.\d+(-[a-zA-Z]+(\.\d+)?)?$/.test(
versionWithoutTime
);
let displayVersion = versionWithoutTime;
if (
!(
isDevelopment ||
versionWithoutTime.startsWith("V") ||
versionWithoutTime.startsWith("v")
)
) {
displayVersion = `V${versionWithoutTime}`;
}
const lastUpdated = formatDate(new Date(timestamp || Date.now())) || "";
const systemInfo = {
isRelease: !isDevelopment,
version: displayVersion,
lastUpdated,
};
const latestReleases = {
web: webData
? {
version: webData.version,
url: `https://github.com/perfect-panel/ppanel-web/releases/tag/v${webData.version}`,
}
: null,
server: serverData
? {
version: serverData.version,
url: `https://github.com/perfect-panel/server/releases/tag/v${serverData.version}`,
}
: null,
};
const hasNewVersion =
latestReleases.web &&
packageJson.version !== latestReleases.web.version.replace(/^v/, "");
const hasServerNewVersion =
latestReleases.server &&
systemInfo.version &&
systemInfo.version.replace(/^V/, "") !==
latestReleases.server.version.replace(/^v/, "");
return {
systemInfo,
latestReleases,
hasNewVersion,
hasServerNewVersion,
};
} catch (error) {
console.error("Failed to fetch version info:", error);
return {
systemInfo: { isRelease: true, version: "V1.0.0", lastUpdated: "" },
latestReleases: { web: null, server: null },
hasNewVersion: false,
hasServerNewVersion: false,
};
}
},
staleTime: 0,
retry: 1,
retryDelay: 10_000,
initialData: {
systemInfo: { isRelease: true, version: "V1.0.0", lastUpdated: "" },
latestReleases: { web: null, server: null },
hasNewVersion: false,
hasServerNewVersion: false,
},
});
const { systemInfo, latestReleases, hasNewVersion, hasServerNewVersion } =
versionInfo;
return (
<Card className="gap-0 p-3">
<CardHeader className="mb-2 p-0">
<CardTitle className="flex items-center justify-between">
{t("systemServices", "System Services")}
<div className="flex items-center space-x-2">
<SystemLogsDialog size="sm" variant="outline" />
<AlertDialog onOpenChange={setOpenRestart} open={openRestart}>
<AlertDialogTrigger asChild>
<Button size="sm" variant="destructive">
{t("systemReboot", "System Reboot")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("confirmSystemReboot", "Confirm System Reboot")}
</AlertDialogTitle>
<AlertDialogDescription>
{t(
"rebootDescription",
"Are you sure you want to reboot the system? This action cannot be undone."
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("cancel", "Cancel")}</AlertDialogCancel>
<Button
disabled={isRestarting}
onClick={async () => {
setIsRestarting(true);
await restartSystem();
await new Promise((resolve) => setTimeout(resolve, 5000));
setIsRestarting(false);
setOpenRestart(false);
}}
>
{isRestarting && (
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
)}
{isRestarting
? t("rebooting", "Rebooting...")
: t("confirmReboot", "Confirm Reboot")}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 p-0">
<div className="flex flex-1 items-center justify-between">
<div className="flex items-center">
<Icon className="mr-2 h-4 w-4 text-green-600" icon="mdi:web" />
<span className="font-medium text-sm">
{t("webVersion", "Web Version")}
</span>
</div>
<div className="flex items-center space-x-2">
<Badge>V{packageJson.version}</Badge>
{hasNewVersion && (
<Link
className="flex items-center space-x-1"
rel="noopener noreferrer"
target="_blank"
to={
latestReleases?.web?.url ||
"https://github.com/perfect-panel/ppanel-web/releases"
}
>
<Badge
className="animate-pulse px-2 py-0.5 text-xs"
variant="destructive"
>
{t("newVersionAvailable", "New Version Available")}
<Icon icon="mdi:open-in-new" />
</Badge>
</Link>
)}
</div>
</div>
<div className="flex flex-1 items-center justify-between">
<div className="flex items-center">
<Icon className="mr-2 h-4 w-4 text-blue-600" icon="mdi:server" />
<span className="font-medium text-sm">
{t("serverVersion", "Server Version")}
</span>
</div>
<div className="flex items-center space-x-2">
<Badge variant={systemInfo?.isRelease ? "default" : "destructive"}>
{systemInfo?.version || "V1.0.0"}
</Badge>
{hasServerNewVersion && (
<Link
className="flex items-center space-x-1"
rel="noopener noreferrer"
target="_blank"
to={
latestReleases?.server?.url ||
"https://github.com/perfect-panel/server/releases"
}
>
<Badge
className="animate-pulse px-2 py-0.5 text-xs"
variant="destructive"
>
{t("newVersionAvailable", "New Version Available")}
<Icon icon="mdi:open-in-new" />
</Badge>
</Link>
)}
</div>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,373 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from "@workspace/ui/components/chart";
import { Empty } from "@workspace/ui/components/empty";
import { Separator } from "@workspace/ui/components/separator";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@workspace/ui/components/tabs";
import { queryUserStatistics } from "@workspace/ui/services/admin/console";
import { useTranslation } from "react-i18next";
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
Label,
Pie,
PieChart,
XAxis,
} from "recharts";
export function UserStatisticsCard() {
const { t, i18n } = useTranslation("dashboard");
const locale = i18n.language;
const UserStatisticsConfig = {
register: {
label: t("register", "Register"),
color: "var(--color-chart-1)",
},
new_purchase: {
label: t("newPurchase", "New Purchase"),
color: "var(--color-chart-2)",
},
repurchase: {
label: t("repurchase", "Repurchase"),
color: "var(--color-chart-3)",
},
};
const { data: UserStatistics } = useQuery({
queryKey: ["queryUserStatistics"],
queryFn: async () => {
const { data } = await queryUserStatistics();
return data.data;
},
});
return (
<Tabs defaultValue="today">
<Card className="h-full pb-0">
<CardHeader className="!flex-row flex items-center justify-between">
<CardTitle>{t("userTitle", "User Statistics")}</CardTitle>
<TabsList>
<TabsTrigger value="today">{t("today", "Today")}</TabsTrigger>
<TabsTrigger value="month">{t("month", "Month")}</TabsTrigger>
<TabsTrigger value="total">{t("total", "Total")}</TabsTrigger>
</TabsList>
</CardHeader>
<TabsContent className="h-full" value="today">
<CardContent className="h-80">
{UserStatistics?.today.register ||
UserStatistics?.today.new_order_users ||
UserStatistics?.today.renewal_order_users ? (
<ChartContainer
className="mx-auto max-h-80"
config={UserStatisticsConfig}
>
<PieChart>
<ChartLegend content={<ChartLegendContent />} />
<ChartTooltip
content={<ChartTooltipContent hideLabel />}
cursor={false}
/>
<Pie
data={[
{
type: "register",
value: UserStatistics?.today.register || 0,
fill: "var(--color-register)",
},
{
type: "new_purchase",
value: UserStatistics?.today.new_order_users || 0,
fill: "var(--color-new_purchase)",
},
{
type: "repurchase",
value: UserStatistics?.today.renewal_order_users || 0,
fill: "var(--color-repurchase)",
},
]}
dataKey="value"
innerRadius={50}
nameKey="type"
strokeWidth={5}
>
<Label
content={({ viewBox }) => {
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
const total =
(UserStatistics?.today.register || 0) +
(UserStatistics?.today.new_order_users || 0) +
(UserStatistics?.today.renewal_order_users || 0);
return (
<text
dominantBaseline="middle"
textAnchor="middle"
x={viewBox.cx}
y={viewBox.cy}
>
<tspan
className="fill-foreground font-bold text-3xl"
x={viewBox.cx}
y={viewBox.cy}
>
{total}
</tspan>
</text>
);
}
}}
/>
</Pie>
</PieChart>
</ChartContainer>
) : (
<div className="flex h-full items-center justify-center">
<Empty />
</div>
)}
</CardContent>
<CardFooter className="!py-5 flex flex-row border-t">
<div className="flex w-full items-center gap-2">
<div className="grid flex-1 auto-rows-min gap-0.5">
<div className="text-muted-foreground text-xs">
{UserStatisticsConfig.register.label}
</div>
<div className="font-bold text-xl tabular-nums leading-none">
{UserStatistics?.today.register}
</div>
</div>
<Separator className="!h-10 mx-2 w-px" orientation="vertical" />
<div className="grid flex-1 auto-rows-min gap-0.5">
<div className="text-muted-foreground text-xs">
{UserStatisticsConfig.new_purchase.label}
</div>
<div className="font-bold text-xl tabular-nums leading-none">
{UserStatistics?.today.new_order_users}
</div>
</div>
<Separator className="!h-10 mx-2 w-px" orientation="vertical" />
<div className="grid flex-1 auto-rows-min gap-0.5">
<div className="text-muted-foreground text-xs">
{UserStatisticsConfig.repurchase.label}
</div>
<div className="font-bold text-xl tabular-nums leading-none">
{UserStatistics?.today.renewal_order_users}
</div>
</div>
</div>
</CardFooter>
</TabsContent>
<TabsContent className="h-full" value="month">
<CardContent className="h-80">
{UserStatistics?.monthly.list &&
UserStatistics?.monthly.list.length > 0 ? (
<ChartContainer
className="max-h-80 w-full"
config={UserStatisticsConfig}
>
<BarChart
accessibilityLayer
data={
UserStatistics?.monthly.list?.map((item) => ({
date: item.date,
register: item.register,
new_purchase: item.new_order_users,
repurchase: item.renewal_order_users,
})) || []
}
>
<CartesianGrid vertical={false} />
<XAxis
axisLine={false}
dataKey="date"
tickFormatter={(value) => {
const [year, month, day] = value.split("-");
return new Date(year, month - 1, day).toLocaleDateString(
locale,
{
month: "short",
day: "numeric",
}
);
}}
tickLine={false}
tickMargin={10}
/>
<Bar
dataKey="register"
fill="var(--color-register)"
radius={[0, 0, 4, 4]}
stackId="a"
/>
<Bar
dataKey="new_purchase"
fill="var(--color-new_purchase)"
radius={0}
stackId="a"
/>
<Bar
dataKey="repurchase"
fill="var(--color-repurchase)"
radius={[4, 4, 0, 0]}
stackId="a"
/>
<ChartTooltip
content={<ChartTooltipContent />}
cursor={false}
/>
<ChartLegend content={<ChartLegendContent />} />
</BarChart>
</ChartContainer>
) : (
<div className="flex h-full items-center justify-center">
<Empty />
</div>
)}
</CardContent>
<CardFooter className="!py-5 flex flex-row border-t">
<div className="flex w-full items-center gap-2">
<div className="grid flex-1 auto-rows-min gap-0.5">
<div className="text-muted-foreground text-xs">
{UserStatisticsConfig.register.label}
</div>
<div className="font-bold text-xl tabular-nums leading-none">
{UserStatistics?.monthly.register}
</div>
</div>
<Separator className="!h-10 mx-2 w-px" orientation="vertical" />
<div className="grid flex-1 auto-rows-min gap-0.5">
<div className="text-muted-foreground text-xs">
{UserStatisticsConfig.new_purchase.label}
</div>
<div className="font-bold text-xl tabular-nums leading-none">
{UserStatistics?.monthly.new_order_users}
</div>
</div>
<Separator className="!h-10 mx-2 w-px" orientation="vertical" />
<div className="grid flex-1 auto-rows-min gap-0.5">
<div className="text-muted-foreground text-xs">
{UserStatisticsConfig.repurchase.label}
</div>
<div className="font-bold text-xl tabular-nums leading-none">
{UserStatistics?.monthly.renewal_order_users}
</div>
</div>
</div>
</CardFooter>
</TabsContent>
<TabsContent className="h-full" value="total">
<CardContent className="h-80">
{UserStatistics?.all.list && UserStatistics?.all.list.length > 0 ? (
<ChartContainer
className="max-h-80 w-full"
config={UserStatisticsConfig}
>
<AreaChart
accessibilityLayer
data={
UserStatistics?.all.list?.map((item) => ({
date: item.date,
register: item.register,
new_purchase: item.new_order_users,
repurchase: item.renewal_order_users,
})) || []
}
margin={{
left: 12,
right: 12,
}}
>
<CartesianGrid vertical={false} />
<XAxis
axisLine={false}
dataKey="date"
tickFormatter={(value) => {
const [year, month] = value.split("-");
return new Date(year, month - 1).toLocaleDateString(
locale,
{
month: "short",
}
);
}}
tickLine={false}
/>
<ChartTooltip
content={<ChartTooltipContent indicator="dot" />}
cursor={false}
/>
<Area
dataKey="register"
fill="var(--color-register)"
fillOpacity={0.4}
stackId="a"
stroke="var(--color-register)"
type="natural"
/>
<Area
dataKey="new_purchase"
fill="var(--color-new_purchase)"
fillOpacity={0.4}
stackId="a"
stroke="var(--color-new_purchase)"
type="natural"
/>
<Area
dataKey="repurchase"
fill="var(--color-repurchase)"
fillOpacity={0.4}
stackId="a"
stroke="var(--color-repurchase)"
type="natural"
/>
<ChartLegend content={<ChartLegendContent />} />
</AreaChart>
</ChartContainer>
) : (
<div className="flex h-full items-center justify-center">
<Empty />
</div>
)}
</CardContent>
<CardFooter className="!py-5 flex flex-row border-t">
<div className="flex w-full items-center gap-2">
<div className="grid flex-1 auto-rows-min gap-0.5">
<div className="text-muted-foreground text-xs">
{UserStatisticsConfig.register.label}
</div>
<div className="font-bold text-xl tabular-nums leading-none">
{UserStatistics?.all.register}
</div>
</div>
</div>
</CardFooter>
</TabsContent>
</Card>
</Tabs>
);
}
@@ -0,0 +1,11 @@
import Billing from "./components/billing";
import Statistics from "./components/statistics";
export default function Dashboard() {
return (
<div className="flex flex-1 flex-col gap-3">
<Statistics />
<Billing type="dashboard" />
</div>
);
}
@@ -0,0 +1,171 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@workspace/ui/components/button";
import {
Form,
FormControl,
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 {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@workspace/ui/components/sheet";
import { MarkdownEditor } from "@workspace/ui/composed/editor/markdown";
import { Icon } from "@workspace/ui/composed/icon";
import { TagInput } from "@workspace/ui/composed/tag-input";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { z } from "zod";
const formSchema = z.object({
title: z.string(),
tags: z.array(z.string()).nullish(),
content: z.string().nullish(),
});
interface DocumentFormProps<T> {
onSubmit: (data: T) => Promise<boolean> | boolean;
initialValues?: T;
loading?: boolean;
trigger: string;
title: string;
}
export default function DocumentForm<T extends Record<string, any>>({
onSubmit,
initialValues,
loading,
trigger,
title,
}: DocumentFormProps<T>) {
const { t } = useTranslation("document");
const [open, setOpen] = useState(false);
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: {
tags: [],
...initialValues,
} as any,
});
useEffect(() => {
form?.reset({
tags: [],
...initialValues,
});
}, [form, initialValues]);
async function handleSubmit(data: { [x: string]: any }) {
const bool = await onSubmit(data as T);
if (bool) setOpen(false);
}
return (
<Sheet onOpenChange={setOpen} open={open}>
<SheetTrigger asChild>
<Button
onClick={() => {
form.reset();
setOpen(true);
}}
>
{trigger}
</Button>
</SheetTrigger>
<SheetContent className="w-[500px] max-w-full md:max-w-screen-md">
<SheetHeader>
<SheetTitle>{title}</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100vh-48px-36px-36px-env(safe-area-inset-top))]">
<Form {...form}>
<form
className="space-y-4 px-6 pt-4"
onSubmit={form.handleSubmit(handleSubmit)}
>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>{t("form.title", "Title")}</FormLabel>
<FormControl>
<Input
placeholder={t(
"form.titlePlaceholder",
"Enter document title"
)}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="tags"
render={({ field }) => (
<FormItem>
<FormLabel>{t("form.tags", "Tags")}</FormLabel>
<FormControl>
<TagInput
onChange={(value) => form.setValue(field.name, value)}
placeholder={t("form.tagsPlaceholder", "Enter tags")}
value={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="content"
render={({ field }) => (
<FormItem>
<FormLabel>{t("form.content", "Content")}</FormLabel>
<FormControl>
<MarkdownEditor
onChange={(value) => {
form.setValue(field.name, value);
}}
value={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<SheetFooter className="flex-row justify-end gap-2 pt-3">
<Button
disabled={loading}
onClick={() => {
setOpen(false);
}}
variant="outline"
>
{t("form.cancel", "Cancel")}
</Button>
<Button disabled={loading} onClick={form.handleSubmit(handleSubmit)}>
{loading && (
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
)}{" "}
{t("form.confirm", "Confirm")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
+179
View File
@@ -0,0 +1,179 @@
import { Button } from "@workspace/ui/components/button";
import { Switch } from "@workspace/ui/components/switch";
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
import {
ProTable,
type ProTableActions,
} from "@workspace/ui/composed/pro-table/pro-table";
import {
batchDeleteDocument,
createDocument,
deleteDocument,
getDocumentList,
updateDocument,
} from "@workspace/ui/services/admin/document";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { formatDate } from "@/utils/common";
import DocumentForm from "./document-form";
export default function Page() {
const { t } = useTranslation("document");
const [loading, setLoading] = useState(false);
const ref = useRef<ProTableActions>(null);
return (
<ProTable<API.Document, { tag: string; search: string }>
action={ref}
actions={{
render(row) {
return [
<DocumentForm<API.UpdateDocumentRequest>
initialValues={row}
key="edit"
loading={loading}
onSubmit={async (values) => {
setLoading(true);
try {
await updateDocument({
...row,
...values,
});
toast.success(t("updateSuccess", "Updated successfully"));
ref.current?.refresh();
return true;
} catch {
return false;
} finally {
setLoading(false);
}
}}
title={t("editDocument", "Edit Document")}
trigger={t("edit", "Edit")}
/>,
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description={t(
"deleteDescription",
"Are you sure you want to delete this document? This action cannot be undone."
)}
key="delete"
onConfirm={async () => {
await deleteDocument({
id: row.id,
});
toast.success(t("deleteSuccess", "Deleted successfully"));
ref.current?.refresh();
}}
title={t("confirmDelete", "Confirm Delete")}
trigger={
<Button variant="destructive">{t("delete", "Delete")}</Button>
}
/>,
];
},
batchRender(rows) {
return [
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description={t(
"deleteDescription",
"Are you sure you want to delete this document? This action cannot be undone."
)}
key="delete"
onConfirm={async () => {
await batchDeleteDocument({
ids: rows.map((item) => item.id),
});
toast.success(t("deleteSuccess", "Deleted successfully"));
ref.current?.refresh();
}}
title={t("confirmDelete", "Confirm Delete")}
trigger={
<Button variant="destructive">{t("delete", "Delete")}</Button>
}
/>,
];
},
}}
columns={[
{
accessorKey: "show",
header: t("show", "Show"),
cell: ({ row }) => (
<Switch
defaultChecked={row.getValue("show")}
onCheckedChange={async (checked) => {
await updateDocument({
...row.original,
show: checked,
});
ref.current?.refresh();
}}
/>
),
},
{
accessorKey: "title",
header: t("title", "Title"),
},
{
accessorKey: "tags",
header: t("tags", "Tags"),
cell: ({ row }) => row.original.tags.join(", "),
},
{
accessorKey: "updated_at",
header: t("updatedAt", "Updated At"),
cell: ({ row }) => formatDate(row.getValue("updated_at")),
},
]}
header={{
title: t("DocumentList", "Document List"),
toolbar: (
<DocumentForm<API.CreateDocumentRequest>
key="create"
loading={loading}
onSubmit={async (values) => {
setLoading(true);
try {
await createDocument({
...values,
show: false,
});
toast.success(t("createSuccess", "Created successfully"));
ref.current?.refresh();
return true;
} catch {
return false;
} finally {
setLoading(false);
}
}}
title={t("createDocument", "Create Document")}
trigger={t("create", "Create")}
/>
),
}}
params={[
{
key: "search",
},
{
key: "tag",
placeholder: t("tags", "Tags"),
},
]}
request={async (pagination, filter) => {
const { data } = await getDocumentList({ ...pagination, ...filter });
return {
list: data.data?.list || [],
total: data.data?.total || 0,
};
}}
/>
);
}
@@ -0,0 +1,108 @@
"use client";
import { useSearch } from "@tanstack/react-router";
import { Badge } from "@workspace/ui/components/badge";
import { ProTable } from "@workspace/ui/composed/pro-table/pro-table";
import { filterBalanceLog } from "@workspace/ui/services/admin/log";
import { useTranslation } from "react-i18next";
import { Display } from "@/components/display";
import { OrderLink } from "@/components/order-link";
import { UserDetail } from "@/sections/user/user-detail";
import { formatDate } from "@/utils/common";
export default function BalanceLogPage() {
const { t } = useTranslation("log");
const sp = useSearch({ strict: false }) as Record<string, string | undefined>;
const today = new Date().toISOString().split("T")[0];
// i18n type declarations for extraction
// t("type.231", "Auto Reset")
// t("type.232", "Advance Reset")
// t("type.233", "Paid Reset")
// t("type.321", "Recharge")
// t("type.322", "Withdraw")
// t("type.323", "Payment")
// t("type.324", "Refund")
// t("type.325", "Reward")
// t("type.326", "Admin Adjust")
// t("type.331", "Purchase")
// t("type.332", "Renewal")
// t("type.333", "Refund")
// t("type.334", "Withdraw")
// t("type.335", "Admin Adjust")
// t("type.341", "Increase")
// t("type.342", "Reduce")
const getBalanceTypeText = (type: number) => {
const typeText = t(`type.${type}`, { defaultValue: "" });
if (!typeText) {
return `${t("unknown", "Unknown")} (${type})`;
}
return typeText;
};
const initialFilters = {
date: sp.date || today,
user_id: sp.user_id ? Number(sp.user_id) : undefined,
};
return (
<ProTable<API.BalanceLog, { search?: string }>
columns={[
{
accessorKey: "user",
header: t("column.user", "User"),
cell: ({ row }) => <UserDetail id={Number(row.original.user_id)} />,
},
{
accessorKey: "amount",
header: t("column.amount", "Amount"),
cell: ({ row }) => (
<Display type="currency" value={row.original.amount} />
),
},
{
accessorKey: "order_no",
header: t("column.orderNo", "Order No."),
cell: ({ row }) => <OrderLink orderId={row.original.order_no} />,
},
{
accessorKey: "balance",
header: t("column.balance", "Balance"),
cell: ({ row }) => (
<Display type="currency" value={row.original.balance} />
),
},
{
accessorKey: "type",
header: t("column.type", "Type"),
cell: ({ row }) => (
<Badge>{getBalanceTypeText(row.original.type)}</Badge>
),
},
{
accessorKey: "timestamp",
header: t("column.time", "Time"),
cell: ({ row }) => formatDate(row.original.timestamp),
},
]}
header={{ title: t("title.balance", "Balance Log") }}
initialFilters={initialFilters}
params={[
{ key: "date", type: "date" },
{ key: "user_id", placeholder: t("column.userId", "User ID") },
]}
request={async (pagination, filter) => {
const { data } = await filterBalanceLog({
page: pagination.page,
size: pagination.size,
date: (filter as any)?.date,
user_id: (filter as any)?.user_id,
});
const list = (data?.data?.list || []) as any[];
const total = Number(data?.data?.total || list.length);
return { list, total };
}}
/>
);
}
@@ -0,0 +1,83 @@
"use client";
import { useSearch } from "@tanstack/react-router";
import { Badge } from "@workspace/ui/components/badge";
import { ProTable } from "@workspace/ui/composed/pro-table/pro-table";
import { filterCommissionLog } from "@workspace/ui/services/admin/log";
import { useTranslation } from "react-i18next";
import { Display } from "@/components/display";
import { OrderLink } from "@/components/order-link";
import { UserDetail } from "@/sections/user/user-detail";
import { formatDate } from "@/utils/common";
export default function CommissionLogPage() {
const { t } = useTranslation("log");
const sp = useSearch({ strict: false }) as Record<string, string | undefined>;
const today = new Date().toISOString().split("T")[0];
const getCommissionTypeText = (type: number) => {
const typeText = t(`type.${type}`, { defaultValue: "" });
if (!typeText) {
return `${t("unknown", "Unknown")} (${type})`;
}
return typeText;
};
const initialFilters = {
date: sp.date || today,
user_id: sp.user_id ? Number(sp.user_id) : undefined,
};
return (
<ProTable<API.CommissionLog, { search?: string }>
columns={[
{
accessorKey: "user",
header: t("column.user", "User"),
cell: ({ row }) => <UserDetail id={Number(row.original.user_id)} />,
},
{
accessorKey: "amount",
header: t("column.amount", "Amount"),
cell: ({ row }) => (
<Display type="currency" value={row.original.amount} />
),
},
{
accessorKey: "order_no",
header: t("column.orderNo", "Order No."),
cell: ({ row }) => <OrderLink orderId={row.original.order_no} />,
},
{
accessorKey: "type",
header: t("column.type", "Type"),
cell: ({ row }) => (
<Badge>{getCommissionTypeText(row.original.type)}</Badge>
),
},
{
accessorKey: "timestamp",
header: t("column.time", "Time"),
cell: ({ row }) => formatDate(row.original.timestamp),
},
]}
header={{ title: t("title.commission", "Commission Log") }}
initialFilters={initialFilters}
params={[
{ key: "date", type: "date" },
{ key: "user_id", placeholder: t("column.userId", "User ID") },
]}
request={async (pagination, filter) => {
const { data } = await filterCommissionLog({
page: pagination.page,
size: pagination.size,
date: (filter as any)?.date,
user_id: (filter as any)?.user_id,
});
const list = (data?.data?.list || []) as any[];
const total = Number(data?.data?.total || list.length);
return { list, total };
}}
/>
);
}
@@ -0,0 +1,89 @@
"use client";
import { useSearch } from "@tanstack/react-router";
import { Badge } from "@workspace/ui/components/badge";
import { ProTable } from "@workspace/ui/composed/pro-table/pro-table";
import { filterEmailLog } from "@workspace/ui/services/admin/log";
import { useTranslation } from "react-i18next";
import { formatDate } from "@/utils/common";
export default function EmailLogPage() {
const { t } = useTranslation("log");
const sp = useSearch({ strict: false }) as Record<string, string | undefined>;
const today = new Date().toISOString().split("T")[0];
const initialFilters = {
search: sp.search || undefined,
date: sp.date || today,
};
return (
<ProTable<API.MessageLog, { search?: string }>
columns={[
{
accessorKey: "platform",
header: t("column.platform", "Platform"),
cell: ({ row }) => <Badge>{row.getValue("platform")}</Badge>,
},
{ accessorKey: "to", header: t("column.to", "To") },
{ accessorKey: "subject", header: t("column.subject", "Subject") },
{
accessorKey: "content",
header: t("column.content", "Content"),
cell: ({ row }) => (
<pre className="wrap-break-word max-w-[480px] overflow-auto whitespace-pre-wrap text-xs">
{JSON.stringify(row.original.content || {}, null, 2)}
</pre>
),
},
{
accessorKey: "status",
header: t("column.status", "Status"),
cell: ({ row }) => {
const status = row.original.status;
const getStatusVariant = (status: any) => {
if (status === 1) {
return "default";
}
if (status === 0) {
return "destructive";
}
return "outline";
};
const getStatusText = (status: any) => {
if (status === 1) return t("sent", "Sent");
if (status === 0) return t("failed", "Failed");
return t("unknown", "Unknown");
};
return (
<Badge variant={getStatusVariant(status)}>
{getStatusText(status)}
</Badge>
);
},
},
{
accessorKey: "created_at",
header: t("column.time", "Time"),
cell: ({ row }) => formatDate(row.original.created_at),
},
]}
header={{ title: t("title.email", "Email Log") }}
initialFilters={initialFilters}
params={[{ key: "search" }, { key: "date", type: "date" }]}
request={async (pagination, filter) => {
const { data } = await filterEmailLog({
page: pagination.page,
size: pagination.size,
search: filter?.search,
date: (filter as any)?.date,
});
const list = ((data?.data?.list || []) as API.MessageLog[]) || [];
const total = Number(data?.data?.total || list.length);
return { list, total };
}}
/>
);
}
+102
View File
@@ -0,0 +1,102 @@
"use client";
import { useSearch } from "@tanstack/react-router";
import { Badge } from "@workspace/ui/components/badge";
import { ProTable } from "@workspace/ui/composed/pro-table/pro-table";
import { filterGiftLog } from "@workspace/ui/services/admin/log";
import { useTranslation } from "react-i18next";
import { Display } from "@/components/display";
import { OrderLink } from "@/components/order-link";
import { UserDetail, UserSubscribeDetail } from "@/sections/user/user-detail";
import { formatDate } from "@/utils/common";
export default function GiftLogPage() {
const { t } = useTranslation("log");
const sp = useSearch({ strict: false }) as Record<string, string | undefined>;
const today = new Date().toISOString().split("T")[0];
const getGiftTypeText = (type: number) => {
const typeText = t(`type.${type}`, { defaultValue: "" });
if (!typeText) {
return `${t("unknown", "Unknown")} (${type})`;
}
return typeText;
};
const initialFilters = {
date: sp.date || today,
user_id: sp.user_id ? Number(sp.user_id) : undefined,
};
return (
<ProTable<API.GiftLog, { search?: string }>
columns={[
{
accessorKey: "user",
header: t("column.user", "User"),
cell: ({ row }) => <UserDetail id={Number(row.original.user_id)} />,
},
{
accessorKey: "subscribe_id",
header: t("column.subscribe", "Subscribe"),
cell: ({ row }) => (
<UserSubscribeDetail
enabled
hoverCard
id={Number(row.original.subscribe_id)}
/>
),
},
{
accessorKey: "order_no",
header: t("column.orderNo", "Order No."),
cell: ({ row }) => <OrderLink orderId={row.original.order_no} />,
},
{
accessorKey: "amount",
header: t("column.amount", "Amount"),
cell: ({ row }) => (
<Display type="currency" value={row.original.amount} />
),
},
{
accessorKey: "balance",
header: t("column.balance", "Balance"),
cell: ({ row }) => (
<Display type="currency" value={row.original.balance} />
),
},
{
accessorKey: "type",
header: t("column.type", "Type"),
cell: ({ row }) => (
<Badge>{getGiftTypeText(row.original.type)}</Badge>
),
},
{ accessorKey: "remark", header: t("column.remark", "Remark") },
{
accessorKey: "timestamp",
header: t("column.time", "Time"),
cell: ({ row }) => formatDate(row.original.timestamp),
},
]}
header={{ title: t("title.gift", "Gift Log") }}
initialFilters={initialFilters}
params={[
{ key: "date", type: "date" },
{ key: "user_id", placeholder: t("column.userId", "User ID") },
]}
request={async (pagination, filter) => {
const { data } = await filterGiftLog({
page: pagination.page,
size: pagination.size,
date: (filter as any)?.date,
user_id: (filter as any)?.user_id,
});
const list = (data?.data?.list || []) as any[];
const total = Number(data?.data?.total || list.length);
return { list, total };
}}
/>
);
}
+106
View File
@@ -0,0 +1,106 @@
"use client";
import { useSearch } from "@tanstack/react-router";
import { Badge } from "@workspace/ui/components/badge";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@workspace/ui/components/tooltip";
import { ProTable } from "@workspace/ui/composed/pro-table/pro-table";
import { filterLoginLog } from "@workspace/ui/services/admin/log";
import { useTranslation } from "react-i18next";
import { IpLink } from "@/components/ip-link";
import { UserDetail } from "@/sections/user/user-detail";
import { formatDate } from "@/utils/common";
export default function LoginLogPage() {
const { t } = useTranslation("log");
const sp = useSearch({ strict: false }) as Record<string, string | undefined>;
const today = new Date().toISOString().split("T")[0];
const initialFilters = {
date: sp.date || today,
user_id: sp.user_id ? Number(sp.user_id) : undefined,
};
return (
<ProTable<API.LoginLog, { date?: string; user_id?: number }>
columns={[
{
accessorKey: "user",
header: t("column.user", "User"),
cell: ({ row }) => (
<div>
<Badge className="capitalize">{row.original.method}</Badge>{" "}
<UserDetail id={Number(row.original.user_id)} />
</div>
),
},
{
accessorKey: "login_ip",
header: t("column.ip", "IP"),
cell: ({ row }) => (
<IpLink ip={String((row.original as any).login_ip || "")} />
),
},
{
accessorKey: "user_agent",
header: t("column.userAgent", "User Agent"),
cell: ({ row }) => {
const userAgent = String(row.original.user_agent || "");
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="max-w-48 cursor-help truncate">
{userAgent}
</div>
</TooltipTrigger>
<TooltipContent>
<p className="wrap-break-word max-w-md">{userAgent}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
},
},
{
accessorKey: "success",
header: t("column.success", "Success"),
cell: ({ row }) => (
<Badge variant={row.original.success ? "default" : "destructive"}>
{row.original.success
? t("success", "Success")
: t("failed", "Failed")}
</Badge>
),
},
{
accessorKey: "timestamp",
header: t("column.time", "Time"),
cell: ({ row }) => formatDate(row.original.timestamp),
},
]}
header={{ title: t("title.login", "Login Log") }}
initialFilters={initialFilters}
params={[
{ key: "date", type: "date" },
{ key: "user_id", placeholder: t("column.userId", "User ID") },
]}
request={async (pagination, filter) => {
const { data } = await filterLoginLog({
page: pagination.page,
size: pagination.size,
date: (filter as any)?.date,
user_id: (filter as any)?.user_id,
});
const list = ((data?.data?.list || []) as API.LoginLog[]) || [];
const total = Number(data?.data?.total || list.length);
return { list, total };
}}
/>
);
}
@@ -0,0 +1,89 @@
"use client";
import { useSearch } from "@tanstack/react-router";
import { Badge } from "@workspace/ui/components/badge";
import { ProTable } from "@workspace/ui/composed/pro-table/pro-table";
import { filterMobileLog } from "@workspace/ui/services/admin/log";
import { useTranslation } from "react-i18next";
import { formatDate } from "@/utils/common";
export default function MobileLogPage() {
const { t } = useTranslation("log");
const sp = useSearch({ strict: false }) as Record<string, string | undefined>;
const today = new Date().toISOString().split("T")[0];
const initialFilters = {
search: sp.search || undefined,
date: sp.date || today,
};
return (
<ProTable<API.MessageLog, { search?: string }>
columns={[
{
accessorKey: "platform",
header: t("column.platform", "Platform"),
cell: ({ row }) => <Badge>{row.getValue("platform")}</Badge>,
},
{ accessorKey: "to", header: t("column.to", "To") },
{ accessorKey: "subject", header: t("column.subject", "Subject") },
{
accessorKey: "content",
header: t("column.content", "Content"),
cell: ({ row }) => (
<pre className="wrap-break-word max-w-[480px] overflow-auto whitespace-pre-wrap text-xs">
{JSON.stringify(row.original.content || {}, null, 2)}
</pre>
),
},
{
accessorKey: "status",
header: t("column.status", "Status"),
cell: ({ row }) => {
const status = row.original.status;
const getStatusVariant = (status: any) => {
if (status === 1) {
return "default";
}
if (status === 0) {
return "destructive";
}
return "outline";
};
const getStatusText = (status: any) => {
if (status === 1) return t("sent", "Sent");
if (status === 0) return t("failed", "Failed");
return t("unknown", "Unknown");
};
return (
<Badge variant={getStatusVariant(status)}>
{getStatusText(status)}
</Badge>
);
},
},
{
accessorKey: "created_at",
header: t("column.time", "Time"),
cell: ({ row }) => formatDate(row.original.created_at),
},
]}
header={{ title: t("title.mobile", "SMS Log") }}
initialFilters={initialFilters}
params={[{ key: "search" }, { key: "date", type: "date" }]}
request={async (pagination, filter) => {
const { data } = await filterMobileLog({
page: pagination.page,
size: pagination.size,
search: filter?.search,
date: (filter as any)?.date,
});
const list = ((data?.data?.list || []) as API.MessageLog[]) || [];
const total = Number(data?.data?.total || list.length);
return { list, total };
}}
/>
);
}
@@ -0,0 +1,99 @@
"use client";
import { useSearch } from "@tanstack/react-router";
import { Badge } from "@workspace/ui/components/badge";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@workspace/ui/components/tooltip";
import { ProTable } from "@workspace/ui/composed/pro-table/pro-table";
import { filterRegisterLog } from "@workspace/ui/services/admin/log";
import { useTranslation } from "react-i18next";
import { IpLink } from "@/components/ip-link";
import { UserDetail } from "@/sections/user/user-detail";
import { formatDate } from "@/utils/common";
export default function RegisterLogPage() {
const { t } = useTranslation("log");
const sp = useSearch({ strict: false }) as Record<string, string | undefined>;
const today = new Date().toISOString().split("T")[0];
const initialFilters = {
date: sp.date || today,
user_id: sp.user_id ? Number(sp.user_id) : undefined,
};
return (
<ProTable<API.RegisterLog, { date?: string; user_id?: number }>
columns={[
{
accessorKey: "user",
header: t("column.user", "User"),
cell: ({ row }) => <UserDetail id={Number(row.original.user_id)} />,
},
{
accessorKey: "auth_method",
header: t("column.identifier", "Identifier"),
cell: ({ row }) => (
<div className="flex items-center">
<Badge className="capitalize">{row.original.auth_method}</Badge>
<span className="ml-1 text-sm">{row.original.identifier}</span>
</div>
),
},
{
accessorKey: "register_ip",
header: t("column.ip", "IP"),
cell: ({ row }) => (
<IpLink ip={String((row.original as any).register_ip || "")} />
),
},
{
accessorKey: "user_agent",
header: t("column.userAgent", "User Agent"),
cell: ({ row }) => {
const userAgent = String(row.original.user_agent || "");
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="max-w-48 cursor-help truncate">
{userAgent}
</div>
</TooltipTrigger>
<TooltipContent>
<p className="wrap-break-word max-w-md">{userAgent}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
},
},
{
accessorKey: "timestamp",
header: t("column.time", "Time"),
cell: ({ row }) => formatDate(row.original.timestamp),
},
]}
header={{ title: t("title.register", "Register Log") }}
initialFilters={initialFilters}
params={[
{ key: "date", type: "date" },
{ key: "user_id", placeholder: t("column.userId", "User ID") },
]}
request={async (pagination, filter) => {
const { data } = await filterRegisterLog({
page: pagination.page,
size: pagination.size,
date: (filter as any)?.date,
user_id: (filter as any)?.user_id,
});
const list = (data?.data?.list || []) as any[];
const total = Number(data?.data?.total || list.length);
return { list, total };
}}
/>
);
}
@@ -0,0 +1,94 @@
"use client";
import { useSearch } from "@tanstack/react-router";
import { Badge } from "@workspace/ui/components/badge";
import { ProTable } from "@workspace/ui/composed/pro-table/pro-table";
import { filterResetSubscribeLog } from "@workspace/ui/services/admin/log";
import { useTranslation } from "react-i18next";
import { OrderLink } from "@/components/order-link";
import { UserDetail, UserSubscribeDetail } from "@/sections/user/user-detail";
import { formatDate } from "@/utils/common";
export default function ResetSubscribeLogPage() {
const { t } = useTranslation("log");
const sp = useSearch({ strict: false }) as Record<string, string | undefined>;
const today = new Date().toISOString().split("T")[0];
const getResetSubscribeTypeText = (type: number) => {
const typeText = t(`type.${type}`, { defaultValue: "" });
if (!typeText) {
return `${t("unknown", "Unknown")} (${type})`;
}
return typeText;
};
const initialFilters = {
date: sp.date || today,
user_subscribe_id: sp.user_subscribe_id
? Number(sp.user_subscribe_id)
: undefined,
};
return (
<ProTable<
API.ResetSubscribeLog,
{ date?: string; user_subscribe_id?: number }
>
columns={[
{
accessorKey: "user",
header: t("column.user", "User"),
cell: ({ row }) => <UserDetail id={Number(row.original.user_id)} />,
},
{
accessorKey: "user_subscribe_id",
header: t("column.subscribeId", "Subscribe ID"),
cell: ({ row }) => (
<UserSubscribeDetail
enabled
hoverCard
id={Number(row.original.user_subscribe_id)}
/>
),
},
{
accessorKey: "type",
header: t("column.type", "Type"),
cell: ({ row }) => (
<Badge>{getResetSubscribeTypeText(row.original.type)}</Badge>
),
},
{
accessorKey: "order_no",
header: t("column.orderNo", "Order No."),
cell: ({ row }) => <OrderLink orderId={row.original.order_no} />,
},
{
accessorKey: "timestamp",
header: t("column.time", "Time"),
cell: ({ row }) => formatDate(row.original.timestamp),
},
]}
header={{ title: t("title.resetSubscribe", "Reset Subscribe Log") }}
initialFilters={initialFilters}
params={[
{ key: "date", type: "date" },
{
key: "user_subscribe_id",
placeholder: t("column.subscribeId", "Subscribe ID"),
},
]}
request={async (pagination, filter) => {
const { data } = await filterResetSubscribeLog({
page: pagination.page,
size: pagination.size,
date: (filter as any)?.date,
user_subscribe_id: (filter as any)?.user_subscribe_id,
});
const list = (data?.data?.list || []) as any[];
const total = Number(data?.data?.total || list.length);
return { list, total };
}}
/>
);
}
@@ -0,0 +1,84 @@
"use client";
import { Link, useSearch } from "@tanstack/react-router";
import { Badge } from "@workspace/ui/components/badge";
import { Button } from "@workspace/ui/components/button";
import { ProTable } from "@workspace/ui/composed/pro-table/pro-table";
import { filterServerTrafficLog } from "@workspace/ui/services/admin/log";
import { formatBytes } from "@workspace/ui/utils/formatting";
import { useTranslation } from "react-i18next";
import { useServer } from "@/stores/server";
export default function ServerTrafficLogPage() {
const { t } = useTranslation("log");
const sp = useSearch({ strict: false }) as Record<string, string | undefined>;
const { getServerName } = useServer();
const today = new Date().toISOString().split("T")[0];
const initialFilters = {
date: sp.date || today,
server_id: sp.server_id ? Number(sp.server_id) : undefined,
};
return (
<ProTable<API.ServerTrafficLog, { date?: string; server_id?: number }>
actions={{
render: (row) => [
<Button asChild key="detail">
<Link
search={{ date: row.date, server_id: row.server_id }}
to="/dashboard/log/traffic-details"
>
{t("detail", "Detail")}
</Link>
</Button>,
],
}}
columns={[
{
accessorKey: "server_id",
header: t("column.server", "Server"),
cell: ({ row }) => (
<div className="flex items-center gap-2">
<Badge>{row.original.server_id}</Badge>
<span>{getServerName(row.original.server_id)}</span>
</div>
),
},
{
accessorKey: "upload",
header: t("column.upload", "Upload"),
cell: ({ row }) => formatBytes(row.original.upload),
},
{
accessorKey: "download",
header: t("column.download", "Download"),
cell: ({ row }) => formatBytes(row.original.download),
},
{
accessorKey: "total",
header: t("column.total", "Total"),
cell: ({ row }) => formatBytes(row.original.total),
},
{ accessorKey: "date", header: t("column.date", "Date") },
]}
header={{ title: t("title.serverTraffic", "Server Traffic Log") }}
initialFilters={initialFilters}
params={[
{ key: "date", type: "date" },
{ key: "server_id", placeholder: t("column.serverId", "Server ID") },
]}
request={async (pagination, filter) => {
const { data } = await filterServerTrafficLog({
page: pagination.page,
size: pagination.size,
date: (filter as any)?.date,
server_id: (filter as any)?.server_id,
});
const list = (data?.data?.list || []) as any[];
const total = Number(data?.data?.total || list.length);
return { list, total };
}}
/>
);
}
@@ -0,0 +1,107 @@
"use client";
import { Link, useSearch } from "@tanstack/react-router";
import { Button } from "@workspace/ui/components/button";
import { ProTable } from "@workspace/ui/composed/pro-table/pro-table";
import { filterUserSubscribeTrafficLog } from "@workspace/ui/services/admin/log";
import { formatBytes } from "@workspace/ui/utils/formatting";
import { useTranslation } from "react-i18next";
import { UserDetail, UserSubscribeDetail } from "@/sections/user/user-detail";
export default function SubscribeTrafficLogPage() {
const { t } = useTranslation("log");
const sp = useSearch({ strict: false }) as Record<string, string | undefined>;
const today = new Date().toISOString().split("T")[0];
const initialFilters = {
date: sp.date || today,
user_id: sp.user_id ? Number(sp.user_id) : undefined,
user_subscribe_id: sp.user_subscribe_id
? Number(sp.user_subscribe_id)
: undefined,
};
return (
<ProTable<
API.UserSubscribeTrafficLog,
{ date?: string; user_id?: number; user_subscribe_id?: number }
>
actions={{
render: (row) => [
<Button asChild key="detail">
<Link
search={{
date: row.date,
user_id: row.user_id,
subscribe_id: row.subscribe_id,
}}
to="/dashboard/log/traffic-details"
>
{t("detail", "Detail")}
</Link>
</Button>,
],
}}
columns={[
{
accessorKey: "user",
header: t("column.user", "User"),
cell: ({ row }) => <UserDetail id={Number(row.original.user_id)} />,
},
{
accessorKey: "subscribe_id",
header: t("column.subscribe", "Subscribe"),
cell: ({ row }) => (
<UserSubscribeDetail
enabled
hoverCard
id={Number(row.original.subscribe_id)}
/>
),
},
{
accessorKey: "upload",
header: t("column.upload", "Upload"),
cell: ({ row }) => formatBytes(row.original.upload),
},
{
accessorKey: "download",
header: t("column.download", "Download"),
cell: ({ row }) => formatBytes(row.original.download),
},
{
accessorKey: "total",
header: t("column.total", "Total"),
cell: ({ row }) => formatBytes(row.original.total),
},
{
accessorKey: "date",
header: t("column.date", "Date"),
},
]}
header={{ title: t("title.subscribeTraffic", "Subscribe Traffic Log") }}
initialFilters={initialFilters}
params={[
{ key: "date", type: "date" },
{ key: "user_id", placeholder: t("column.userId", "User ID") },
{
key: "user_subscribe_id",
placeholder: t("column.subscribeId", "Subscribe ID"),
},
]}
request={async (pagination, filter) => {
const { data } = await filterUserSubscribeTrafficLog({
page: pagination.page,
size: pagination.size,
date: (filter as any)?.date,
user_id: (filter as any)?.user_id,
user_subscribe_id: (filter as any)?.user_subscribe_id,
});
const list =
((data?.data?.list || []) as API.UserSubscribeTrafficLog[]) || [];
const total = Number(data?.data?.total || list.length);
return { list, total };
}}
/>
);
}
@@ -0,0 +1,107 @@
"use client";
import { useSearch } from "@tanstack/react-router";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@workspace/ui/components/tooltip";
import { ProTable } from "@workspace/ui/composed/pro-table/pro-table";
import { filterSubscribeLog } from "@workspace/ui/services/admin/log";
import { useTranslation } from "react-i18next";
import { IpLink } from "@/components/ip-link";
import { UserDetail, UserSubscribeDetail } from "@/sections/user/user-detail";
import { formatDate } from "@/utils/common";
export default function SubscribeLogPage() {
const { t } = useTranslation("log");
const sp = useSearch({ strict: false }) as Record<string, string | undefined>;
const today = new Date().toISOString().split("T")[0];
const initialFilters = {
date: sp.date || today,
user_id: sp.user_id ? Number(sp.user_id) : undefined,
user_subscribe_id: sp.user_subscribe_id
? Number(sp.user_subscribe_id)
: undefined,
};
return (
<ProTable<API.SubscribeLog, { date?: string; user_id?: number }>
columns={[
{
accessorKey: "user",
header: t("column.user", "User"),
cell: ({ row }) => <UserDetail id={Number(row.original.user_id)} />,
},
{
accessorKey: "user_subscribe_id",
header: t("column.subscribe", "Subscribe"),
cell: ({ row }) => (
<UserSubscribeDetail
enabled
hoverCard
id={Number(row.original.user_subscribe_id)}
/>
),
},
{
accessorKey: "client_ip",
header: t("column.ip", "IP"),
cell: ({ row }) => (
<IpLink ip={String((row.original as any).client_ip || "")} />
),
},
{
accessorKey: "user_agent",
header: t("column.userAgent", "User Agent"),
cell: ({ row }) => {
const userAgent = String(row.original.user_agent || "");
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="max-w-48 cursor-help truncate">
{userAgent}
</div>
</TooltipTrigger>
<TooltipContent>
<p className="wrap-break-word max-w-md">{userAgent}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
},
},
{
accessorKey: "timestamp",
header: t("column.time", "Time"),
cell: ({ row }) => formatDate(row.original.timestamp),
},
]}
header={{ title: t("title.subscribe", "Subscribe Log") }}
initialFilters={initialFilters}
params={[
{ key: "date", type: "date" },
{ key: "user_id", placeholder: t("column.userId", "User ID") },
{
key: "user_subscribe_id",
placeholder: t("column.subscribeId", "Subscribe ID"),
},
]}
request={async (pagination, filter) => {
const { data } = await filterSubscribeLog({
page: pagination.page,
size: pagination.size,
date: (filter as any)?.date,
user_id: (filter as any)?.user_id,
user_subscribe_id: (filter as any)?.user_subscribe_id,
});
const list = (data?.data?.list || []) as any[];
const total = Number(data?.data?.total || list.length);
return { list, total };
}}
/>
);
}
@@ -0,0 +1,95 @@
"use client";
import { useSearch } from "@tanstack/react-router";
import { ProTable } from "@workspace/ui/composed/pro-table/pro-table";
import { filterTrafficLogDetails } from "@workspace/ui/services/admin/log";
import { formatBytes } from "@workspace/ui/utils/formatting";
import { useTranslation } from "react-i18next";
import { UserDetail, UserSubscribeDetail } from "@/sections/user/user-detail";
import { useServer } from "@/stores/server";
import { formatDate } from "@/utils/common";
export default function TrafficDetailsPage() {
const { t } = useTranslation("log");
const sp = useSearch({ strict: false }) as Record<string, string | undefined>;
const { getServerName } = useServer();
const today = new Date().toISOString().split("T")[0];
const initialFilters = {
date: sp.date || today,
server_id: sp.server_id ? Number(sp.server_id) : undefined,
user_id: sp.user_id ? Number(sp.user_id) : undefined,
subscribe_id: sp.subscribe_id ? Number(sp.subscribe_id) : undefined,
};
return (
<ProTable<API.TrafficLogDetails, { search?: string }>
columns={[
{
accessorKey: "server_id",
header: t("column.server", "Server"),
cell: ({ row }) => (
<span>
{getServerName(row.original.server_id)} ({row.original.server_id})
</span>
),
},
{
accessorKey: "user_id",
header: t("column.user", "User"),
cell: ({ row }) => <UserDetail id={Number(row.original.user_id)} />,
},
{
accessorKey: "subscribe_id",
header: t("column.subscribe", "Subscribe"),
cell: ({ row }) => (
<UserSubscribeDetail
enabled
hoverCard
id={Number(row.original.subscribe_id)}
/>
),
},
{
accessorKey: "upload",
header: t("column.upload", "Upload"),
cell: ({ row }) => formatBytes(row.original.upload),
},
{
accessorKey: "download",
header: t("column.download", "Download"),
cell: ({ row }) => formatBytes(row.original.download),
},
{
accessorKey: "timestamp",
header: t("column.time", "Time"),
cell: ({ row }) => formatDate(row.original.timestamp),
},
]}
header={{ title: t("title.trafficDetails", "Traffic Details") }}
initialFilters={initialFilters}
params={[
{ key: "date", type: "date" },
{ key: "server_id", placeholder: t("column.serverId", "Server ID") },
{ key: "user_id", placeholder: t("column.userId", "User ID") },
{
key: "subscribe_id",
placeholder: t("column.subscribeId", "Subscribe ID"),
},
]}
request={async (pagination, filter) => {
const { data } = await filterTrafficLogDetails({
page: pagination.page,
size: pagination.size,
date: (filter as any)?.date,
server_id: (filter as any)?.server_id,
user_id: (filter as any)?.user_id,
subscribe_id: (filter as any)?.subscribe_id,
});
const list = (data?.data?.list || []) as any[];
const total = Number(data?.data?.total || list.length);
return { list, total };
}}
/>
);
}
@@ -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>
);
}
+275
View File
@@ -0,0 +1,275 @@
"use client";
import { Badge } from "@workspace/ui/components/badge";
import { Button } from "@workspace/ui/components/button";
import { Switch } from "@workspace/ui/components/switch";
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
import {
ProTable,
type ProTableActions,
} from "@workspace/ui/composed/pro-table/pro-table";
import {
createNode,
deleteNode,
filterNodeList,
resetSortWithNode,
toggleNodeStatus,
updateNode,
} from "@workspace/ui/services/admin/server";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useNode } from "@/stores/node";
import { useServer } from "@/stores/server";
import NodeForm from "./node-form";
export default function Nodes() {
const { t } = useTranslation("nodes");
const ref = useRef<ProTableActions>(null);
const [loading, setLoading] = useState(false);
// Use our zustand store for server data
const { getServerName, getServerAddress, getProtocolPort } = useServer();
const { fetchNodes, fetchTags } = useNode();
return (
<ProTable<API.Node, { search: string }>
action={ref}
actions={{
render: (row) => [
<NodeForm
initialValues={row}
key="edit"
loading={loading}
onSubmit={async (values) => {
setLoading(true);
try {
const body: API.UpdateNodeRequest = {
...row,
...values,
} as any;
await updateNode(body);
toast.success(t("updated", "Updated"));
ref.current?.refresh();
fetchNodes();
fetchTags();
setLoading(false);
return true;
} catch {
setLoading(false);
return false;
}
}}
title={t("drawerEditTitle", "Edit Node")}
trigger={t("edit", "Edit")}
/>,
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description={t(
"confirmDeleteDesc",
"This action cannot be undone."
)}
key="delete"
onConfirm={async () => {
await deleteNode({ id: row.id } as any);
toast.success(t("deleted", "Deleted"));
ref.current?.refresh();
fetchNodes();
fetchTags();
}}
title={t("confirmDeleteTitle", "Delete this node?")}
trigger={
<Button variant="destructive">{t("delete", "Delete")}</Button>
}
/>,
<Button
key="copy"
onClick={async () => {
const {
id: _id,
sort: _sort,
enabled: _enabled,
updated_at: _updated_at,
created_at: _created_at,
...rest
} = row as any;
await createNode({
...rest,
enabled: false,
});
toast.success(t("copied", "Copied"));
ref.current?.refresh();
fetchNodes();
fetchTags();
}}
variant="outline"
>
{t("copy", "Copy")}
</Button>,
],
batchRender(rows) {
return [
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description={t(
"confirmDeleteDesc",
"This action cannot be undone."
)}
key="delete"
onConfirm={async () => {
await Promise.all(
rows.map((r) => deleteNode({ id: r.id } as any))
);
toast.success(t("deleted", "Deleted"));
ref.current?.refresh();
fetchNodes();
fetchTags();
}}
title={t("confirmDeleteTitle", "Delete this node?")}
trigger={
<Button variant="destructive">{t("delete", "Delete")}</Button>
}
/>,
];
},
}}
columns={[
{
id: "enabled",
header: t("enabled", "Enabled"),
cell: ({ row }) => (
<Switch
checked={row.original.enabled}
onCheckedChange={async (v) => {
await toggleNodeStatus({ id: row.original.id, enable: v });
toast.success(
v ? t("enabled_on", "Enabled") : t("enabled_off", "Disabled")
);
ref.current?.refresh();
fetchNodes();
fetchTags();
}}
/>
),
},
{ accessorKey: "name", header: t("name", "Name") },
{
id: "address_port",
header: `${t("address", "Address")}:${t("port", "Port")}`,
cell: ({ row }) =>
`${row.original.address || "—"}:${row.original.port || "—"}`,
},
{
id: "server_id",
header: t("server", "Server"),
cell: ({ row }) =>
`${getServerName(row.original.server_id)}:${getServerAddress(row.original.server_id)}`,
},
{
id: "protocol",
header: ` ${t("protocol", "Protocol")}:${t("port", "Port")}`,
cell: ({ row }) =>
`${row.original.protocol}:${getProtocolPort(row.original.server_id, row.original.protocol)}`,
},
{
accessorKey: "tags",
header: t("tags", "Tags"),
cell: ({ row }) => (
<div className="flex flex-wrap gap-1">
{(row.original.tags || []).length === 0
? "—"
: row.original.tags.map((tg) => (
<Badge key={tg} variant="outline">
{tg}
</Badge>
))}
</div>
),
},
]}
header={{
title: t("pageTitle", "Nodes"),
toolbar: (
<NodeForm
loading={loading}
onSubmit={async (values) => {
setLoading(true);
try {
const body: API.CreateNodeRequest = {
name: values.name,
server_id: Number(values.server_id!),
protocol: values.protocol,
address: values.address,
port: Number(values.port!),
tags: values.tags || [],
enabled: false,
};
await createNode(body);
toast.success(t("created", "Created"));
ref.current?.refresh();
fetchNodes();
fetchTags();
setLoading(false);
return true;
} catch {
setLoading(false);
return false;
}
}}
title={t("drawerCreateTitle", "Create Node")}
trigger={t("create", "Create")}
/>
),
}}
onSort={async (source, target, items) => {
const sourceIndex = items.findIndex(
(item) => String(item.id) === source
);
const targetIndex = items.findIndex(
(item) => String(item.id) === target
);
const originalSorts = items.map((item) => item.sort);
const [movedItem] = items.splice(sourceIndex, 1);
items.splice(targetIndex, 0, movedItem!);
const updatedItems = items.map((item, index) => {
const originalSort = originalSorts[index];
const newSort = originalSort !== undefined ? originalSort : item.sort;
return { ...item, sort: newSort };
});
const changedItems = updatedItems.filter(
(item, index) => item.sort !== items[index]?.sort
);
if (changedItems.length > 0) {
resetSortWithNode({
sort: changedItems.map((item) => ({
id: item.id,
sort: item.sort,
})) as API.SortItem[],
});
toast.success(t("sorted_success", "Sorted successfully"));
}
return updatedItems;
}}
params={[{ key: "search" }]}
request={async (pagination, filter) => {
const { data } = await filterNodeList({
page: pagination.page,
size: pagination.size,
search: filter?.search || undefined,
});
const list = (data?.data?.list || []) as API.Node[];
const total = Number(data?.data?.total || list.length);
return { list, total };
}}
/>
);
}
+416
View File
@@ -0,0 +1,416 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@workspace/ui/components/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@workspace/ui/components/form";
import { ScrollArea } from "@workspace/ui/components/scroll-area";
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@workspace/ui/components/sheet";
import { Combobox } from "@workspace/ui/composed/combobox";
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
import TagInput from "@workspace/ui/composed/tag-input";
import type { TFunction } from "i18next";
import { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { z } from "zod";
import { useNode } from "@/stores/node";
import { useServer } from "@/stores/server";
export type ProtocolName =
| "shadowsocks"
| "vmess"
| "vless"
| "trojan"
| "hysteria"
| "tuic"
| "anytls"
| "naive"
| "http"
| "socks"
| "mieru";
const buildSchema = (t: TFunction) =>
z.object({
name: z
.string()
.trim()
.min(1, t("errors.nameRequired", "Please enter a name")),
server_id: z
.number({ message: t("errors.serverRequired", "Please select a server") })
.int()
.gt(0, t("errors.serverRequired", "Please select a server"))
.optional(),
protocol: z
.string()
.min(1, t("errors.protocolRequired", "Please select a protocol")),
address: z
.string()
.trim()
.min(1, t("errors.serverAddrRequired", "Please enter an entry address")),
port: z
.number({
message: t("errors.portRange", "Port must be between 1 and 65535"),
})
.int()
.min(1, t("errors.portRange", "Port must be between 1 and 65535"))
.max(65_535, t("errors.portRange", "Port must be between 1 and 65535")),
tags: z.array(z.string()),
});
export type NodeFormValues = z.infer<ReturnType<typeof buildSchema>>;
export default function NodeForm(props: {
trigger: string;
title: string;
loading?: boolean;
initialValues?: Partial<NodeFormValues>;
onSubmit: (values: NodeFormValues) => Promise<boolean> | boolean;
}) {
const { trigger, title, loading, initialValues, onSubmit } = props;
const { t } = useTranslation("nodes");
const Scheme = useMemo(() => buildSchema(t), [t]);
const [open, setOpen] = useState(false);
const [autoFilledFields, setAutoFilledFields] = useState<Set<string>>(
new Set()
);
const addAutoFilledField = (fieldName: string) => {
setAutoFilledFields((prev) => new Set(prev).add(fieldName));
};
const removeAutoFilledField = (fieldName: string) => {
setAutoFilledFields((prev) => {
const newSet = new Set(prev);
newSet.delete(fieldName);
return newSet;
});
};
const form = useForm<NodeFormValues>({
resolver: zodResolver(Scheme),
defaultValues: {
name: "",
server_id: undefined,
protocol: "",
address: "",
port: 0,
tags: [],
...initialValues,
},
});
const serverId = form.watch("server_id");
const { servers, getAvailableProtocols } = useServer();
const { tags } = useNode();
const existingTags: string[] = tags || [];
const availableProtocols = getAvailableProtocols(serverId);
useEffect(() => {
if (initialValues) {
form.reset({
name: "",
server_id: undefined,
protocol: "",
address: "",
port: 0,
tags: [],
...initialValues,
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialValues]);
function handleServerChange(nextId?: number | null) {
const id = nextId ?? undefined;
form.setValue("server_id", id);
if (!id) {
setAutoFilledFields(new Set());
return;
}
const selectedServer = servers.find((s) => s.id === id);
if (!selectedServer) return;
const currentValues = form.getValues();
const fieldsToFill: string[] = [];
if (!currentValues.name || autoFilledFields.has("name")) {
form.setValue("name", selectedServer.name as string, {
shouldDirty: false,
});
fieldsToFill.push("name");
}
if (!currentValues.address || autoFilledFields.has("address")) {
form.setValue("address", selectedServer.address as string, {
shouldDirty: false,
});
fieldsToFill.push("address");
}
const protocols = getAvailableProtocols(id);
const firstProtocol = protocols[0];
if (
firstProtocol &&
(!currentValues.protocol || autoFilledFields.has("protocol"))
) {
form.setValue("protocol", firstProtocol.protocol, { shouldDirty: false });
fieldsToFill.push("protocol");
if (
!currentValues.port ||
currentValues.port === 0 ||
autoFilledFields.has("port")
) {
const port = firstProtocol.port || 0;
form.setValue("port", port, { shouldDirty: false });
fieldsToFill.push("port");
}
}
setAutoFilledFields(new Set(fieldsToFill));
}
const handleManualFieldChange = (
fieldName: keyof NodeFormValues,
value: any
) => {
form.setValue(fieldName, value);
removeAutoFilledField(fieldName);
};
function handleProtocolChange(nextProto?: ProtocolName | null) {
const protocol = (nextProto || "") as ProtocolName | "";
form.setValue("protocol", protocol);
if (!(protocol && serverId)) {
removeAutoFilledField("protocol");
return;
}
const currentValues = form.getValues();
const isPortAutoFilled = autoFilledFields.has("port");
removeAutoFilledField("protocol");
if (!currentValues.port || currentValues.port === 0 || isPortAutoFilled) {
const protocolData = availableProtocols.find(
(p) => p.protocol === protocol
);
if (protocolData) {
const port = protocolData.port || 0;
form.setValue("port", port, { shouldDirty: false });
addAutoFilledField("port");
}
}
}
async function handleSubmit(values: NodeFormValues) {
const result = await onSubmit(values);
if (result) {
setOpen(false);
setAutoFilledFields(new Set());
}
}
return (
<Sheet onOpenChange={setOpen} open={open}>
<SheetTrigger asChild>
<Button
onClick={() => {
form.reset();
setAutoFilledFields(new Set());
}}
>
{trigger}
</Button>
</SheetTrigger>
<SheetContent className="w-[560px] max-w-full">
<SheetHeader>
<SheetTitle>{title}</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))] px-6 pt-4">
<Form {...form}>
<form className="grid grid-cols-1 gap-4">
<FormField
control={form.control}
name="server_id"
render={({ field }) => (
<FormItem>
<FormLabel>{t("server", "Server")}</FormLabel>
<FormControl>
<Combobox<number, false>
onChange={(v) => handleServerChange(v)}
options={servers.map((s) => ({
value: s.id,
label: `${s.name} (${(s.address as any) || ""})`,
}))}
placeholder={t("select_server", "Select server…")}
value={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="protocol"
render={({ field }) => (
<FormItem>
<FormLabel>{t("protocol", "Protocol")}</FormLabel>
<FormControl>
<Combobox<string, false>
onChange={(v) =>
handleProtocolChange((v as ProtocolName) || null)
}
options={availableProtocols.map((p) => ({
value: p.protocol,
label: `${p.protocol}${p.port ? ` (${p.port})` : ""}`,
}))}
placeholder={t("select_protocol", "Select protocol…")}
value={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("name", "Name")}</FormLabel>
<FormControl>
<EnhancedInput
{...field}
onValueChange={(v) =>
handleManualFieldChange("name", v as string)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="address"
render={({ field }) => (
<FormItem>
<FormLabel>{t("address", "Address")}</FormLabel>
<FormControl>
<EnhancedInput
{...field}
onValueChange={(v) =>
handleManualFieldChange("address", v as string)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="port"
render={({ field }) => (
<FormItem>
<FormLabel>{t("port", "Port")}</FormLabel>
<FormControl>
<EnhancedInput
{...field}
max={65_535}
min={1}
onValueChange={(v) =>
handleManualFieldChange("port", Number(v))
}
placeholder="1-65535"
type="number"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="tags"
render={({ field }) => (
<FormItem>
<FormLabel>{t("tags", "Tags")}</FormLabel>
<FormControl>
<TagInput
onChange={(v) => form.setValue(field.name, v)}
options={existingTags}
placeholder={t(
"tags_placeholder",
"Use Enter or comma (,) to add multiple tags"
)}
value={field.value || []}
/>
</FormControl>
<FormDescription>
{t(
"tags_description",
"Permission grouping tag (incl. plan binding and delivery policies)."
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<SheetFooter className="flex-row justify-end gap-2 pt-3">
<Button
disabled={loading}
onClick={() => setOpen(false)}
variant="outline"
>
{t("cancel", "Cancel")}
</Button>
<Button
disabled={loading}
onClick={form.handleSubmit(handleSubmit, (errors) => {
const key = Object.keys(errors)[0] as keyof typeof errors;
if (key) toast.error(String(errors[key]?.message));
return false;
})}
>
{t("confirm", "Confirm")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
+265
View File
@@ -0,0 +1,265 @@
import { Badge } from "@workspace/ui/components/badge";
import { Button } from "@workspace/ui/components/button";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@workspace/ui/components/hover-card";
import { Separator } from "@workspace/ui/components/separator";
import { Combobox } from "@workspace/ui/composed/combobox";
import {
ProTable,
type ProTableActions,
} from "@workspace/ui/composed/pro-table/pro-table";
import { cn } from "@workspace/ui/lib/utils";
import {
getOrderList,
updateOrderStatus,
} from "@workspace/ui/services/admin/order";
import { useRef } from "react";
import { useTranslation } from "react-i18next";
import { Display } from "@/components/display";
import { useSubscribe } from "@/stores/subscribe";
import { formatDate } from "@/utils/common";
import { UserDetail } from "../user/user-detail";
export default function Order() {
const { t } = useTranslation("order");
const statusOptions = [
{
value: 1,
label: t("status.1", "Pending"),
className: "bg-orange-500",
},
{ value: 2, label: t("status.2", "Paid"), className: "bg-green-500" },
{
value: 3,
label: t("status.3", "Cancelled"),
className: "bg-gray-500",
},
{ value: 4, label: t("status.4", "Closed"), className: "bg-red-500" },
{
value: 5,
label: t("status.5", "Completed"),
className: "bg-green-500",
},
];
const typeOptions = [
{ value: 1, label: t("type.1", "New Purchase") },
{ value: 2, label: t("type.2", "Renewal") },
{ value: 3, label: t("type.3", "Reset Traffic") },
{ value: 4, label: t("type.4", "Recharge") },
];
const ref = useRef<ProTableActions>(null);
const { subscribes, getSubscribeName } = useSubscribe();
return (
<ProTable<API.Order, any>
action={ref}
columns={[
{
accessorKey: "order_no",
header: t("orderNumber", "Order Number"),
},
{
accessorKey: "type",
header: t("type.0", "Type"),
cell: ({ row }) => {
const type = row.getValue("type") as number;
return (
typeOptions.find((opt) => opt.value === type)?.label ||
t(`type.${type}`)
);
},
},
{
accessorKey: "subscribe_id",
header: t("subscribe", "Subscribe"),
cell: ({ row }) => {
const order = row.original as API.Order;
if (order.type === 4) {
const type = row.getValue("type") as number;
return (
typeOptions.find((opt) => opt.value === type)?.label ||
t(`type.${type}`)
);
}
const name = getSubscribeName(order.subscribe_id);
const quantity = order.quantity;
return name ? `${name} × ${quantity}` : "";
},
},
{
accessorKey: "amount",
header: t("amount", "Amount"),
cell: ({ row }) => {
const order = row.original as API.Order;
return (
<HoverCard>
<HoverCardTrigger asChild>
<Button className="p-0" variant="link">
<Display type="currency" value={order.amount} />
</Button>
</HoverCardTrigger>
<HoverCardContent>
<div className="grid gap-3">
{order.trade_no && (
<>
<div className="font-semibold">
{t("tradeNo", "Transaction Number")}
</div>
<span className="text-muted-foreground">
{order.trade_no}
</span>
<Separator className="my-2" />
</>
)}
<ul className="grid gap-3">
<li className="flex items-center justify-between">
<span className="text-muted-foreground">
{t("subscribePrice", "Subscription Price")}
</span>
<span>
<Display type="currency" value={order.price} />
</span>
</li>
<li className="flex items-center justify-between">
<span className="text-muted-foreground">
{t("discount", "Discount Amount")}
</span>
<span>
<Display type="currency" value={order.discount} />
</span>
</li>
<li className="flex items-center justify-between">
<span className="text-muted-foreground">
{t("couponDiscount", "Coupon Discount")}
</span>
<span>
<Display
type="currency"
value={order.coupon_discount}
/>
</span>
</li>
<li className="flex items-center justify-between">
<span className="text-muted-foreground">
{t("feeAmount", "Fee Amount")}
</span>
<span>
<Display type="currency" value={order.fee_amount} />
</span>
</li>
<li className="flex items-center justify-between font-semibold">
<span className="text-muted-foreground">
{t("total", "Total")}
</span>
<span>
<Display type="currency" value={order.amount} />
</span>
</li>
</ul>
</div>
<Separator className="my-4" />
<ul className="grid gap-3">
<li className="flex items-center justify-between">
<span className="text-muted-foreground">
{t("method", "Payment Method")}
</span>
<span>
{order.payment?.name || order.payment?.platform}
</span>
</li>
</ul>
</HoverCardContent>
</HoverCard>
);
},
},
{
accessorKey: "user_id",
header: t("user", "User"),
cell: ({ row }) => {
const order = row.original as API.Order;
return <UserDetail id={order.user_id} />;
},
},
{
accessorKey: "updated_at",
header: t("updateTime", "Update Time"),
cell: ({ row }) => {
const order = row.original as API.Order;
return formatDate(order.updated_at);
},
},
{
accessorKey: "status",
header: t("status.0", "Status"),
cell: ({ row }) => {
const order = row.original as API.Order;
const option = statusOptions.find(
(opt) => opt.value === order.status
);
if ([1, 3, 4].includes(row.getValue("status"))) {
return (
<Combobox<number, false>
className={cn(option?.className)}
onChange={async (value) => {
await updateOrderStatus({
id: order.id,
status: value,
});
ref.current?.refresh();
}}
options={statusOptions}
placeholder={t("status.0", "Status")}
value={order.status}
/>
);
}
return (
<Badge>
{option?.label || t(`status.${row.getValue("status")}`)}
</Badge>
);
},
},
]}
params={[
{
key: "status",
placeholder: t("status.0", "Status"),
options: statusOptions.map((item) => ({
label: item.label,
value: String(item.value),
})),
},
{
key: "subscribe_id",
placeholder: `${t("subscribe", "Subscribe")}`,
options: subscribes?.map((item) => ({
label: item.name!,
value: String(item.id),
})),
},
{ key: "search" },
{
key: "user_id",
placeholder: `${t("user", "User")} ID`,
options: undefined,
},
]}
request={async (pagination, filter) => {
const { data } = await getOrderList({ ...pagination, ...filter });
return {
list: data.data?.list || [],
total: data.data?.total || 0,
};
}}
/>
);
}
+13
View File
@@ -0,0 +1,13 @@
import Billing from "../dashboard/components/billing";
import PaymentTable from "./payment-table";
export default function Payment() {
return (
<>
<PaymentTable />
<div className="mt-5 flex flex-col gap-3">
<Billing type="payment" />
</div>
</>
);
}
@@ -0,0 +1,470 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@workspace/ui/components/button";
import {
Form,
FormControl,
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@workspace/ui/components/select";
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@workspace/ui/components/sheet";
import { MarkdownEditor } from "@workspace/ui/composed/editor/markdown";
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
import { Icon } from "@workspace/ui/composed/icon";
import { getPaymentPlatform } from "@workspace/ui/services/admin/payment";
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 * as z from "zod";
import { useGlobalStore } from "@/stores/global";
interface PaymentFormProps<T extends { platform?: string }> {
trigger: React.ReactNode;
title: string;
loading?: boolean;
initialValues?: T;
onSubmit: (values: T) => Promise<boolean>;
isEdit?: boolean;
}
export default function PaymentForm<T extends { platform?: string }>({
trigger,
title,
loading,
initialValues,
onSubmit,
isEdit,
}: PaymentFormProps<T>) {
const { t } = useTranslation("payment");
const { common } = useGlobalStore();
const { currency } = common;
const [open, setOpen] = useState(false);
const { data: platformData } = useQuery({
queryKey: ["getPaymentPlatform"],
queryFn: async () => {
const { data } = await getPaymentPlatform();
return data?.data?.list || [];
},
});
const formSchema = z.object({
name: z.string().min(1, { message: t("nameRequired", "Name is required") }),
platform: z.string().optional(),
icon: z.string().optional(),
domain: z.string().optional(),
config: z.any(),
fee_mode: z.number().min(0).max(2),
fee_percent: z.number().optional(),
fee_amount: z.number().optional(),
description: z.string().optional(),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
platform: "",
icon: "",
domain: "",
config: {},
fee_mode: 0,
fee_percent: 0,
fee_amount: 0,
...(initialValues as any),
},
});
const feeMode = form.watch("fee_mode");
const platformValue = form.watch("platform");
const configValues = form.watch("config");
const currentPlatform = platformData?.find(
(p) => p.platform === platformValue
);
const currentFieldDescriptions =
currentPlatform?.platform_field_description || {};
const configFields = Object.keys(currentFieldDescriptions) || [];
const platformUrl = currentPlatform?.platform_url || "";
useEffect(() => {
if (feeMode === 0) {
form.setValue("fee_amount", 0);
form.setValue("fee_percent", 0);
} else if (feeMode === 1) {
form.setValue("fee_amount", 0);
} else if (feeMode === 2) {
form.setValue("fee_percent", 0);
}
}, [feeMode, form]);
const handleClose = () => {
form.reset();
setOpen(false);
};
const handleSubmit = async (values: z.infer<typeof formSchema>) => {
const cleanedValues = { ...values };
if (values.fee_mode === 0) {
cleanedValues.fee_amount = undefined;
cleanedValues.fee_percent = undefined;
} else if (values.fee_mode === 1) {
cleanedValues.fee_amount = undefined;
} else if (values.fee_mode === 2) {
cleanedValues.fee_percent = undefined;
}
const success = await onSubmit(cleanedValues as unknown as T);
if (success) {
handleClose();
}
};
const openPlatformUrl = () => {
if (platformUrl) {
window.open(platformUrl, "_blank");
}
};
return (
<Sheet onOpenChange={setOpen} open={open}>
<SheetTrigger asChild>{trigger}</SheetTrigger>
<SheetContent className="w-[550px] max-w-full md:max-w-screen-md">
<SheetHeader>
<SheetTitle>{title}</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100vh-48px-36px-36px-24px-env(safe-area-inset-top))]">
<Form {...form}>
<form
className="space-y-6 px-6 pt-4"
onSubmit={form.handleSubmit(handleSubmit)}
>
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("name", "Name")}</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={(value) =>
form.setValue("name", value as string)
}
placeholder={t(
"namePlaceholder",
"Enter payment method name"
)}
value={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="icon"
render={({ field }) => (
<FormItem>
<FormLabel>{t("icon", "Icon")}</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={(value) =>
form.setValue("icon", value as string)
}
placeholder={t("iconPlaceholder", "Enter icon URL")}
value={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="domain"
render={({ field }) => (
<FormItem>
<FormLabel>{t("domain", "Domain")}</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={(value) =>
form.setValue("domain", value as string)
}
placeholder="http(s)://example.com"
value={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="space-y-4">
<FormField
control={form.control}
name="fee_mode"
render={({ field }) => (
<FormItem>
<FormLabel>{t("handlingFee", "Handling Fee")}</FormLabel>
<FormControl>
<RadioGroup
className="flex flex-wrap gap-4"
onValueChange={(value) =>
field.onChange(Number.parseInt(value, 10))
}
value={field.value.toString()}
>
<FormItem className="flex items-center space-x-2">
<FormControl>
<RadioGroupItem value="0" />
</FormControl>
<FormLabel className="!mt-0 cursor-pointer">
{t("noFee", "No Fee")}
</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-2">
<FormControl>
<RadioGroupItem value="1" />
</FormControl>
<FormLabel className="!mt-0 cursor-pointer">
{t("percentFee", "Percentage")}
</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-2">
<FormControl>
<RadioGroupItem value="2" />
</FormControl>
<FormLabel className="!mt-0 cursor-pointer">
{t("fixedFee", "Fixed Amount")}
</FormLabel>
</FormItem>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{feeMode === 1 && (
<div className="grid grid-cols-1 sm:w-1/2">
<FormField
control={form.control}
name="fee_percent"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("feePercent", "Fee Percentage")}
</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={field.onChange}
step="0.01"
suffix="%"
type="number"
value={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
{feeMode === 2 && (
<div className="grid grid-cols-1 sm:w-1/2">
<FormField
control={form.control}
name="fee_amount"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("feeAmount", "Fixed Amount")}
</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={(value) =>
field.onChange(
unitConversion("dollarsToCents", value)
)
}
prefix={currency.currency_symbol}
step="0.01"
suffix={currency.currency_unit}
type="number"
value={unitConversion(
"centsToDollars",
field.value
)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
</div>
<div className="space-y-4">
{(!platformValue ||
platformData?.find((p) => p.platform === platformValue)) && (
<FormField
control={form.control}
name="platform"
render={({ field }) => (
<FormItem>
<FormLabel>{t("platform", "Platform")}</FormLabel>
<Select
defaultValue={field.value}
disabled={isEdit && Boolean(initialValues?.platform)}
onValueChange={(value) => {
form.setValue("platform", value as string);
form.setValue("config", {});
}}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={t(
"selectPlatform",
"Select Platform"
)}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{platformData?.map((platform) => (
<SelectItem
key={platform.platform}
value={platform.platform}
>
{platform.platform}
</SelectItem>
))}
</SelectContent>
</Select>
{platformUrl ? (
<div className="mt-1 flex justify-end">
<Button
className="h-6 px-2 text-xs"
onClick={openPlatformUrl}
size="sm"
variant="ghost"
>
<Icon
className="mr-1 h-3 w-3"
icon="tabler:external-link"
/>
{t("applyForPayment", "Apply for Payment")}
</Button>
</div>
) : (
<div className="mt-1 h-6" />
)}
<FormMessage />
</FormItem>
)}
/>
)}
{configFields.length > 0 && (
<div className="mt-4 space-y-4">
{configFields.map((fieldKey) => (
<FormItem key={fieldKey}>
<FormLabel>
{currentFieldDescriptions[fieldKey]}
</FormLabel>
<FormControl>
<EnhancedInput
disabled={fieldKey === "webhook_secret"}
onValueChange={(value) => {
const newConfig = { ...configValues };
newConfig[fieldKey] = value;
form.setValue("config", newConfig);
}}
placeholder={t("configPlaceholder", {
field: currentFieldDescriptions[fieldKey],
defaultValue:
"Please fill in the provided {{field}} configuration",
})}
value={
configValues &&
configValues[fieldKey] !== undefined
? configValues[fieldKey]
: ""
}
/>
</FormControl>
</FormItem>
))}
</div>
)}
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>{t("description", "Description")}</FormLabel>
<FormControl>
<MarkdownEditor
onChange={(value: string | undefined) =>
form.setValue(field.name, value as string)
}
value={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</form>
</Form>
</ScrollArea>
<SheetFooter className="flex-row justify-end gap-2 pt-3">
<Button disabled={loading} onClick={handleClose} variant="outline">
{t("cancel", "Cancel")}
</Button>
<Button disabled={loading} onClick={form.handleSubmit(handleSubmit)}>
{loading && (
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
)}
{t("submit", "Submit")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,241 @@
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@workspace/ui/components/avatar";
import { Badge } from "@workspace/ui/components/badge";
import { Button } from "@workspace/ui/components/button";
import { Switch } from "@workspace/ui/components/switch";
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
import {
ProTable,
type ProTableActions,
} from "@workspace/ui/composed/pro-table/pro-table";
import {
createPaymentMethod,
deletePaymentMethod,
getPaymentMethodList,
updatePaymentMethod,
} from "@workspace/ui/services/admin/payment";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Display } from "@/components/display";
import PaymentForm from "./payment-form";
export default function PaymentTable() {
const { t } = useTranslation("payment");
const [loading, setLoading] = useState(false);
const ref = useRef<ProTableActions>(null);
return (
<ProTable<API.PaymentConfig, { search: string }>
action={ref}
actions={{
render: (row) => [
<PaymentForm<API.UpdatePaymentMethodRequest>
initialValues={row}
isEdit
key="edit"
loading={loading}
onSubmit={async (values) => {
setLoading(true);
try {
await updatePaymentMethod({
...row,
...values,
});
toast.success(t("updateSuccess", "Updated successfully"));
ref.current?.refresh();
setLoading(false);
return true;
} catch {
setLoading(false);
return false;
}
}}
title={t("editPayment", "Edit Payment Method")}
trigger={<Button>{t("edit", "Edit")}</Button>}
/>,
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description={t(
"deleteWarning",
"Are you sure you want to delete this payment method? This action cannot be undone."
)}
key="delete"
onConfirm={async () => {
await deletePaymentMethod({
id: row.id,
});
toast.success(t("deleteSuccess", "Deleted successfully"));
ref.current?.refresh();
}}
title={t("confirmDelete", "Confirm Delete")}
trigger={
<Button variant="destructive">{t("delete", "Delete")}</Button>
}
/>,
<Button
key="copy"
onClick={async () => {
setLoading(true);
try {
const { id: _id, ...params } = row;
await createPaymentMethod({
...params,
enable: false,
});
toast.success(t("copySuccess", "Copied successfully"));
ref.current?.refresh();
setLoading(false);
return true;
} catch {
setLoading(false);
return false;
}
}}
variant="outline"
>
{t("copy", "Copy")}
</Button>,
],
batchRender(rows) {
return [
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description={t(
"deleteWarning",
"Are you sure you want to delete this payment method? This action cannot be undone."
)}
key="delete"
onConfirm={async () => {
for (const row of rows) {
await deletePaymentMethod({ id: row.id });
}
toast.success(t("deleteSuccess", "Deleted successfully"));
ref.current?.refresh();
}}
title={t("confirmDelete", "Confirm Delete")}
trigger={
<Button variant="destructive">
{t("batchDelete", "Batch Delete")}
</Button>
}
/>,
];
},
}}
columns={[
{
accessorKey: "enable",
header: t("enable", "Enable"),
cell: ({ row }) => (
<Switch
checked={Boolean(row.getValue("enable"))}
onCheckedChange={async (checked) => {
await updatePaymentMethod({
...row.original,
enable: checked,
});
ref.current?.refresh();
}}
/>
),
},
{
accessorKey: "icon",
header: t("icon", "Icon"),
cell: ({ row }) => {
const icon = row.getValue("icon") as string;
return (
<Avatar className="h-8 w-8">
{icon ? (
<AvatarImage alt={row.getValue("name")} src={icon} />
) : null}
<AvatarFallback>
{(row.getValue("name") as string)?.charAt(0) || "?"}
</AvatarFallback>
</Avatar>
);
},
},
{
accessorKey: "name",
header: t("name", "Name"),
},
{
accessorKey: "platform",
header: t("platform", "Platform"),
cell: ({ row }) => <Badge>{t(row.original.platform)}</Badge>,
},
{
accessorKey: "notify_url",
header: t("notify_url", "Notify URL"),
},
{
accessorKey: "fee",
header: t("handlingFee", "Handling Fee"),
cell: ({ row }) => {
const feeMode = row.original.fee_mode;
if (feeMode === 1) {
return <Badge>{row.original.fee_percent}%</Badge>;
}
if (feeMode === 2) {
return (
<Badge>
<Display type="currency" value={row.original.fee_amount} />
</Badge>
);
}
return "--";
},
},
]}
header={{
title: t("paymentManagement", "Payment Management"),
toolbar: (
<PaymentForm<API.CreatePaymentMethodRequest>
loading={loading}
onSubmit={async (values) => {
setLoading(true);
try {
await createPaymentMethod({
...values,
enable: false,
});
toast.success(t("createSuccess", "Created successfully"));
ref.current?.refresh();
setLoading(false);
return true;
} catch {
setLoading(false);
return false;
}
}}
title={t("createPayment", "Add Payment Method")}
trigger={<Button>{t("create", "Add Payment Method")}</Button>}
/>
),
}}
params={[
{
key: "search",
placeholder: t("searchPlaceholder", "Enter search terms"),
},
]}
request={async (pagination, filter) => {
const { data } = await getPaymentMethodList({
...pagination,
...filter,
});
return {
list: data?.data?.list || [],
total: data?.data?.total || 0,
};
}}
/>
);
}
@@ -0,0 +1,5 @@
import SubscribeTable from "./subscribe-table";
export default function Product() {
return <SubscribeTable />;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,328 @@
"use client";
import { Badge } from "@workspace/ui/components/badge";
import { Button } from "@workspace/ui/components/button";
import { Switch } from "@workspace/ui/components/switch";
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
import {
ProTable,
type ProTableActions,
} from "@workspace/ui/composed/pro-table/pro-table";
import {
batchDeleteSubscribe,
createSubscribe,
deleteSubscribe,
getSubscribeList,
subscribeSort,
updateSubscribe,
} from "@workspace/ui/services/admin/subscribe";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Display } from "@/components/display";
import { useSubscribe } from "@/stores/subscribe";
import SubscribeForm from "./subscribe-form";
export default function SubscribeTable() {
const { t } = useTranslation("product");
const [loading, setLoading] = useState(false);
const ref = useRef<ProTableActions>(null);
const { fetchSubscribes } = useSubscribe();
return (
<ProTable<API.SubscribeItem, { group_id: number; query: string }>
action={ref}
actions={{
render: (row) => [
<SubscribeForm<API.SubscribeItem>
initialValues={row}
key="edit"
loading={loading}
onSubmit={async (values) => {
setLoading(true);
try {
await updateSubscribe({
...row,
...values,
} as API.UpdateSubscribeRequest);
toast.success(t("updateSuccess"));
ref.current?.refresh();
fetchSubscribes();
setLoading(false);
return true;
} catch {
setLoading(false);
return false;
}
}}
title={t("editSubscribe")}
trigger={t("edit")}
/>,
<ConfirmButton
cancelText={t("cancel")}
confirmText={t("confirm")}
description={t("deleteWarning")}
key="delete"
onConfirm={async () => {
await deleteSubscribe({
id: row.id!,
});
toast.success(t("deleteSuccess"));
ref.current?.refresh();
fetchSubscribes();
}}
title={t("confirmDelete")}
trigger={<Button variant="destructive">{t("delete")}</Button>}
/>,
<Button
key="copy"
onClick={async () => {
setLoading(true);
try {
const {
id: _id,
sort: _sort,
sell: _sell,
updated_at: _updated_at,
created_at: _created_at,
...params
} = row;
await createSubscribe({
...params,
show: false,
sell: false,
} as API.CreateSubscribeRequest);
toast.success(t("copySuccess"));
ref.current?.refresh();
fetchSubscribes();
setLoading(false);
return true;
} catch {
setLoading(false);
return false;
}
}}
variant="secondary"
>
{t("copy")}
</Button>,
],
batchRender: (rows) => [
<ConfirmButton
cancelText={t("cancel")}
confirmText={t("confirm")}
description={t("deleteWarning")}
key="delete"
onConfirm={async () => {
await batchDeleteSubscribe({
ids: rows.map((item) => item.id) as number[],
});
toast.success(t("deleteSuccess"));
ref.current?.reset();
fetchSubscribes();
}}
title={t("confirmDelete")}
trigger={<Button variant="destructive">{t("delete")}</Button>}
/>,
],
}}
columns={[
{
accessorKey: "show",
header: t("show"),
cell: ({ row }) => (
<Switch
defaultChecked={row.getValue("show")}
onCheckedChange={async (checked) => {
await updateSubscribe({
...row.original,
show: checked,
} as API.UpdateSubscribeRequest);
ref.current?.refresh();
fetchSubscribes();
}}
/>
),
},
{
accessorKey: "sell",
header: t("sell"),
cell: ({ row }) => (
<Switch
defaultChecked={row.getValue("sell")}
onCheckedChange={async (checked) => {
await updateSubscribe({
...row.original,
sell: checked,
} as API.UpdateSubscribeRequest);
ref.current?.refresh();
fetchSubscribes();
}}
/>
),
},
{
accessorKey: "name",
header: t("name"),
},
{
accessorKey: "unit_price",
header: t("unitPrice"),
cell: ({ row }) => (
<>
<Display type="currency" value={row.getValue("unit_price")} />/
{t(
row.original.unit_time
? `form.${row.original.unit_time}`
: "form.Month"
)}
</>
),
},
{
accessorKey: "replacement",
header: t("replacement"),
cell: ({ row }) => (
<Display type="currency" value={row.getValue("replacement")} />
),
},
{
accessorKey: "traffic",
header: t("traffic"),
cell: ({ row }) => (
<Display type="traffic" unlimited value={row.getValue("traffic")} />
),
},
{
accessorKey: "device_limit",
header: t("deviceLimit"),
cell: ({ row }) => (
<Display
type="number"
unlimited
value={row.getValue("device_limit")}
/>
),
},
{
accessorKey: "inventory",
header: t("inventory"),
cell: ({ row }) => (
<Display
type="number"
unlimited
value={
row.getValue("inventory") === -1 ? 0 : row.getValue("inventory")
}
/>
),
},
{
accessorKey: "quota",
header: t("quota"),
cell: ({ row }) => (
<Display type="number" unlimited value={row.getValue("quota")} />
),
},
{
accessorKey: "language",
header: t("language"),
cell: ({ row }) => {
const language = row.getValue("language") as string;
return language ? (
<Badge variant="outline">{language}</Badge>
) : (
"--"
);
},
},
{
accessorKey: "sold",
header: t("sold"),
cell: ({ row }) => (
<Badge variant="outline">{row.getValue("sold")}</Badge>
),
},
]}
header={{
toolbar: (
<SubscribeForm<API.CreateSubscribeRequest>
loading={loading}
onSubmit={async (values) => {
setLoading(true);
try {
await createSubscribe({
...values,
show: false,
sell: false,
});
toast.success(t("createSuccess"));
ref.current?.refresh();
fetchSubscribes();
setLoading(false);
return true;
} catch {
setLoading(false);
return false;
}
}}
title={t("createSubscribe")}
trigger={t("create")}
/>
),
}}
onSort={async (source, target, items) => {
const sourceIndex = items.findIndex(
(item) => String(item.id) === source
);
const targetIndex = items.findIndex(
(item) => String(item.id) === target
);
const originalSorts = items.map((item) => item.sort);
const [movedItem] = items.splice(sourceIndex, 1);
items.splice(targetIndex, 0, movedItem!);
const updatedItems = items.map((item, index) => {
const originalSort = originalSorts[index];
const newSort = originalSort !== undefined ? originalSort : item.sort;
return { ...item, sort: newSort };
});
const changedItems = updatedItems.filter(
(item, index) => item.sort !== items[index]?.sort
);
if (changedItems.length > 0) {
subscribeSort({
sort: changedItems.map((item) => ({
id: item.id,
sort: item.sort,
})) as API.SortItem[],
});
}
return updatedItems;
}}
params={[
{
key: "search",
},
]}
request={async (pagination, filters) => {
const { data } = await getSubscribeList({
...pagination,
...filters,
});
return {
list: data.data?.list || [],
total: data.data?.total || 0,
};
}}
/>
);
}
@@ -0,0 +1,145 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@workspace/ui/components/button";
import { Card, CardContent } from "@workspace/ui/components/card";
import { ScrollArea } from "@workspace/ui/components/scroll-area";
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@workspace/ui/components/sheet";
import { ArrayInput } from "@workspace/ui/composed/dynamic-Inputs";
import { Icon } from "@workspace/ui/composed/icon";
import {
getNodeMultiplier,
setNodeMultiplier,
} from "@workspace/ui/services/admin/system";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
export default function DynamicMultiplier() {
const { t } = useTranslation("servers");
const [open, setOpen] = useState(false);
const [timeSlots, setTimeSlots] = useState<API.TimePeriod[]>([]);
const { data: periodsResp, refetch: refetchPeriods } = useQuery({
queryKey: ["getNodeMultiplier"],
queryFn: async () => {
const { data } = await getNodeMultiplier();
return (data.data?.periods || []) as API.TimePeriod[];
},
enabled: open,
});
useEffect(() => {
if (periodsResp) {
setTimeSlots(periodsResp);
}
}, [periodsResp]);
async function savePeriods() {
await setNodeMultiplier({ periods: timeSlots });
await refetchPeriods();
toast.success(t("server_config.saveSuccess", "Saved successfully"));
setOpen(false);
}
return (
<Sheet onOpenChange={setOpen} open={open}>
<SheetTrigger asChild>
<Card>
<CardContent>
<div className="flex cursor-pointer items-center justify-between">
<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:clock-time-eight"
/>
</div>
<div className="flex-1">
<p className="font-medium">
{t(
"server_config.dynamic_multiplier",
"Dynamic multiplier"
)}
</p>
<p className="truncate text-muted-foreground text-sm">
{t(
"server_config.dynamic_multiplier_desc",
"Define time slots and multipliers to adjust traffic accounting."
)}
</p>
</div>
</div>
<Icon className="size-6" icon="mdi:chevron-right" />
</div>
</CardContent>
</Card>
</SheetTrigger>
<SheetContent className="w-[600px] max-w-full md:max-w-3xl">
<SheetHeader>
<SheetTitle>
{t("server_config.dynamic_multiplier", "Dynamic multiplier")}
</SheetTitle>
<SheetDescription>
{t(
"server_config.dynamic_multiplier_desc",
"Define time slots and multipliers to adjust traffic accounting."
)}
</SheetDescription>
</SheetHeader>
<ScrollArea className="h-[calc(100dvh-48px-36px-60px-env(safe-area-inset-top))] px-6">
<div className="space-y-4 pt-4">
<ArrayInput<API.TimePeriod>
fields={[
{
name: "start_time",
prefix: t("server_config.fields.start_time", "Start time"),
type: "time",
step: "1",
},
{
name: "end_time",
prefix: t("server_config.fields.end_time", "End time"),
type: "time",
step: "1",
},
{
name: "multiplier",
prefix: t("server_config.fields.multiplier", "Multiplier"),
type: "number",
placeholder: "0",
},
]}
onChange={setTimeSlots}
value={timeSlots}
/>
</div>
</ScrollArea>
<SheetFooter className="flex-row justify-between pt-3">
<Button
onClick={() => setTimeSlots(periodsResp || [])}
variant="outline"
>
{t("server_config.fields.reset", "Reset")}
</Button>
<div className="flex gap-2">
<Button onClick={() => setOpen(false)} variant="outline">
{t("actions.cancel", "Cancel")}
</Button>
<Button onClick={savePeriods}>{t("actions.save", "Save")}</Button>
</div>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,112 @@
export const protocols = [
"shadowsocks",
"vmess",
"vless",
"trojan",
"hysteria",
"tuic",
"anytls",
"socks",
"naive",
"http",
"mieru",
] as const;
// Global label map for display; fallback to raw value if missing
export const LABELS = {
// transport
tcp: "TCP",
udp: "UDP",
websocket: "WebSocket",
grpc: "gRPC",
mkcp: "mKCP",
httpupgrade: "HTTP Upgrade",
xhttp: "XHTTP",
// security
none: "NONE",
tls: "TLS",
reality: "Reality",
// fingerprint
chrome: "Chrome",
firefox: "Firefox",
safari: "Safari",
ios: "IOS",
android: "Android",
edge: "edge",
"360": "360",
qq: "QQ",
// multiplex
low: "Low",
middle: "Middle",
high: "High",
} as const;
// Flat arrays for enum-like sets
export const SS_CIPHERS = [
"aes-128-gcm",
"aes-192-gcm",
"aes-256-gcm",
"chacha20-ietf-poly1305",
"2022-blake3-aes-128-gcm",
"2022-blake3-aes-256-gcm",
"2022-blake3-chacha20-poly1305",
] as const;
export const TRANSPORTS = {
vmess: ["tcp", "websocket", "grpc"] as const,
vless: ["tcp", "websocket", "grpc", "mkcp", "httpupgrade", "xhttp"] as const,
trojan: ["tcp", "websocket", "grpc"] as const,
mieru: ["tcp", "udp"] as const,
} as const;
export const SECURITY = {
shadowsocks: ["none", "http", "tls"] as const,
vmess: ["none", "tls"] as const,
vless: ["none", "tls", "reality"] as const,
trojan: ["tls"] as const,
hysteria: ["tls"] as const,
tuic: ["tls"] as const,
anytls: ["tls"] as const,
naive: ["none", "tls"] as const,
http: ["none", "tls"] as const,
} as const;
export const FLOWS = {
vless: [
"none",
"xtls-rprx-direct",
"xtls-rprx-splice",
"xtls-rprx-vision",
] as const,
} as const;
export const TUIC_UDP_RELAY_MODES = ["native", "quic"] as const;
export const TUIC_CONGESTION = ["bbr", "cubic", "new_reno"] as const;
export const XHTTP_MODES = [
"auto",
"packet-up",
"stream-up",
"stream-one",
] as const;
export const ENCRYPTION_TYPES = ["none", "mlkem768x25519plus"] as const;
export const ENCRYPTION_MODES = ["native", "xorpub", "random"] as const;
export const ENCRYPTION_RTT = ["0rtt", "1rtt"] as const;
export const FINGERPRINTS = [
"chrome",
"firefox",
"safari",
"ios",
"android",
"edge",
"360",
"qq",
] as const;
export const CERT_MODES = ["none", "http", "dns", "self"] as const;
export const multiplexLevels = ["none", "low", "middle", "high"] as const;
export function getLabel(value: string): string {
const label = (LABELS as Record<string, string>)[value];
return label ?? value.toUpperCase();
}
@@ -0,0 +1,192 @@
import { XHTTP_MODES } from "./constants";
import type { ProtocolType } from "./types";
export function getProtocolDefaultConfig(proto: ProtocolType) {
switch (proto) {
case "shadowsocks":
return {
type: "shadowsocks",
enable: false,
port: null,
cipher: "chacha20-ietf-poly1305",
server_key: null,
obfs: "none",
obfs_host: null,
obfs_path: null,
sni: null,
allow_insecure: null,
cert_mode: "none",
cert_dns_provider: null,
cert_dns_env: null,
ratio: 1,
} as any;
case "vmess":
return {
type: "vmess",
enable: false,
host: null,
port: null,
transport: "tcp",
security: "none",
path: null,
service_name: null,
sni: null,
allow_insecure: null,
fingerprint: "chrome",
cert_mode: "none",
cert_dns_provider: null,
cert_dns_env: null,
ratio: 1,
} as any;
case "vless":
return {
type: "vless",
enable: false,
host: null,
port: null,
transport: "tcp",
security: "none",
flow: "none",
path: null,
service_name: null,
sni: null,
allow_insecure: null,
fingerprint: "chrome",
reality_server_addr: null,
reality_server_port: null,
reality_private_key: null,
reality_public_key: null,
reality_short_id: null,
xhttp_mode: XHTTP_MODES[0], // 'auto'
xhttp_extra: null,
encryption: "none",
encryption_mode: null,
encryption_rtt: null,
encryption_ticket: null,
encryption_server_padding: null,
encryption_private_key: null,
encryption_client_padding: null,
encryption_password: null,
cert_mode: "none",
cert_dns_provider: null,
cert_dns_env: null,
ratio: 1,
} as any;
case "trojan":
return {
type: "trojan",
enable: false,
host: null,
port: null,
transport: "tcp",
security: "tls",
path: null,
service_name: null,
sni: null,
allow_insecure: null,
fingerprint: "chrome",
cert_mode: "none",
cert_dns_provider: null,
cert_dns_env: null,
ratio: 1,
} as any;
case "hysteria":
return {
type: "hysteria",
enable: false,
port: null,
hop_ports: null,
hop_interval: null,
obfs: "none",
obfs_password: null,
security: "tls",
up_mbps: null,
down_mbps: null,
sni: null,
allow_insecure: null,
fingerprint: "chrome",
cert_mode: "none",
cert_dns_provider: null,
cert_dns_env: null,
ratio: 1,
} as any;
case "tuic":
return {
type: "tuic",
enable: false,
port: null,
disable_sni: false,
reduce_rtt: false,
udp_relay_mode: "native",
congestion_controller: "bbr",
security: "tls",
sni: null,
allow_insecure: false,
fingerprint: "chrome",
cert_mode: "none",
cert_dns_provider: null,
cert_dns_env: null,
ratio: 1,
} as any;
case "socks":
return {
type: "socks",
enable: false,
port: null,
ratio: 1,
} as any;
case "naive":
return {
type: "naive",
enable: false,
port: null,
security: "none",
sni: null,
allow_insecure: null,
fingerprint: "chrome",
cert_mode: "none",
cert_dns_provider: null,
cert_dns_env: null,
ratio: 1,
} as any;
case "http":
return {
type: "http",
enable: false,
port: null,
security: "none",
sni: null,
allow_insecure: null,
fingerprint: "chrome",
cert_mode: "none",
cert_dns_provider: null,
cert_dns_env: null,
ratio: 1,
} as any;
case "mieru":
return {
type: "mieru",
enable: false,
port: null,
multiplex: "none",
transport: "tcp",
} as any;
case "anytls":
return {
type: "anytls",
enable: false,
port: null,
security: "tls",
padding_scheme: null,
sni: null,
allow_insecure: false,
fingerprint: "chrome",
cert_mode: "none",
cert_dns_provider: null,
cert_dns_env: null,
ratio: 1,
} as any;
default:
return {} as any;
}
}
@@ -0,0 +1,26 @@
// Re-export all constants
export {
ENCRYPTION_MODES,
ENCRYPTION_RTT,
ENCRYPTION_TYPES,
FINGERPRINTS,
FLOWS,
getLabel,
LABELS,
multiplexLevels,
protocols,
SECURITY,
SS_CIPHERS,
TRANSPORTS,
TUIC_CONGESTION,
TUIC_UDP_RELAY_MODES,
XHTTP_MODES,
} from "./constants";
// Re-export defaults
export { getProtocolDefaultConfig } from "./defaults";
// Re-export all schemas
export { formSchema, protocolApiScheme } from "./schemas";
// Re-export all types
export type { FieldConfig, ProtocolType } from "./types";
// Re-export hooks
export { useProtocolFields } from "./useProtocolFields";
@@ -0,0 +1,225 @@
import { z } from "zod";
import {
CERT_MODES,
ENCRYPTION_MODES,
ENCRYPTION_RTT,
ENCRYPTION_TYPES,
FLOWS,
multiplexLevels,
SECURITY,
SS_CIPHERS,
TRANSPORTS,
TUIC_CONGESTION,
TUIC_UDP_RELAY_MODES,
XHTTP_MODES,
} from "./constants";
const nullableString = z.string().nullish();
const nullableBool = z.boolean().nullish();
const nullablePort = z.number().int().min(0).max(65_535).nullish();
const nullableRatio = z.number().min(0).nullish();
const ss = z.object({
ratio: nullableRatio,
type: z.literal("shadowsocks"),
enable: nullableBool,
port: nullablePort,
cipher: z.enum(SS_CIPHERS).nullish(),
server_key: nullableString,
obfs: z.enum(["none", "http", "tls"] as const).nullish(),
obfs_host: nullableString,
obfs_path: nullableString,
cert_mode: z.enum(CERT_MODES).nullish(),
cert_dns_provider: nullableString,
cert_dns_env: nullableString,
});
const vmess = z.object({
ratio: nullableRatio,
type: z.literal("vmess"),
enable: nullableBool,
host: nullableString,
port: nullablePort,
transport: z.enum(TRANSPORTS.vmess).nullish(),
security: z.enum(SECURITY.vmess).nullish(),
path: nullableString,
service_name: nullableString,
sni: nullableString,
allow_insecure: nullableBool,
fingerprint: nullableString,
cert_mode: z.enum(CERT_MODES).nullish(),
cert_dns_provider: nullableString,
cert_dns_env: nullableString,
});
const vless = z.object({
ratio: nullableRatio,
type: z.literal("vless"),
enable: nullableBool,
host: nullableString,
port: nullablePort,
transport: z.enum(TRANSPORTS.vless).nullish(),
security: z.enum(SECURITY.vless).nullish(),
path: nullableString,
service_name: nullableString,
flow: z.enum(FLOWS.vless).nullish(),
sni: nullableString,
allow_insecure: nullableBool,
fingerprint: nullableString,
reality_server_addr: nullableString,
reality_server_port: nullablePort,
reality_private_key: nullableString,
reality_public_key: nullableString,
reality_short_id: nullableString,
xhttp_mode: z.enum(XHTTP_MODES).nullish(),
xhttp_extra: nullableString,
encryption: z.enum(ENCRYPTION_TYPES).nullish(),
encryption_mode: z.enum(ENCRYPTION_MODES).nullish(),
encryption_rtt: z.enum(ENCRYPTION_RTT).nullish(),
encryption_ticket: nullableString,
encryption_server_padding: nullableString,
encryption_private_key: nullableString,
encryption_client_padding: nullableString,
encryption_password: nullableString,
cert_mode: z.enum(CERT_MODES).nullish(),
cert_dns_provider: nullableString,
cert_dns_env: nullableString,
});
const trojan = z.object({
ratio: nullableRatio,
type: z.literal("trojan"),
enable: nullableBool,
host: nullableString,
port: nullablePort,
transport: z.enum(TRANSPORTS.trojan).nullish(),
security: z.enum(SECURITY.trojan).nullish(),
path: nullableString,
service_name: nullableString,
sni: nullableString,
allow_insecure: nullableBool,
fingerprint: nullableString,
cert_mode: z.enum(CERT_MODES).nullish(),
cert_dns_provider: nullableString,
cert_dns_env: nullableString,
});
const hysteria = z.object({
ratio: nullableRatio,
type: z.literal("hysteria"),
enable: nullableBool,
hop_ports: nullableString,
hop_interval: z.number().nullish(),
obfs_password: nullableString,
obfs: z.enum(["none", "salamander"] as const).nullish(),
port: nullablePort,
security: z.enum(SECURITY.hysteria).nullish(),
sni: nullableString,
allow_insecure: nullableBool,
fingerprint: nullableString,
up_mbps: z.number().nullish(),
down_mbps: z.number().nullish(),
cert_mode: z.enum(CERT_MODES).nullish(),
cert_dns_provider: nullableString,
cert_dns_env: nullableString,
});
const tuic = z.object({
ratio: nullableRatio,
type: z.literal("tuic"),
enable: nullableBool,
host: nullableString,
port: nullablePort,
disable_sni: z.boolean().nullish(),
reduce_rtt: z.boolean().nullish(),
udp_relay_mode: z.enum(TUIC_UDP_RELAY_MODES).nullish(),
congestion_controller: z.enum(TUIC_CONGESTION).nullish(),
security: z.enum(SECURITY.tuic).nullish(),
sni: nullableString,
allow_insecure: nullableBool,
fingerprint: nullableString,
cert_mode: z.enum(CERT_MODES).nullish(),
cert_dns_provider: nullableString,
cert_dns_env: nullableString,
});
const anytls = z.object({
ratio: nullableRatio,
type: z.literal("anytls"),
enable: nullableBool,
port: nullablePort,
security: z.enum(SECURITY.anytls).nullish(),
sni: nullableString,
allow_insecure: nullableBool,
fingerprint: nullableString,
padding_scheme: nullableString,
cert_mode: z.enum(CERT_MODES).nullish(),
cert_dns_provider: nullableString,
cert_dns_env: nullableString,
});
const socks = z.object({
ratio: nullableRatio,
type: z.literal("socks"),
enable: nullableBool,
port: nullablePort,
});
const naive = z.object({
ratio: nullableRatio,
type: z.literal("naive"),
enable: nullableBool,
port: nullablePort,
security: z.enum(SECURITY.naive).nullish(),
sni: nullableString,
allow_insecure: nullableBool,
fingerprint: nullableString,
cert_mode: z.enum(CERT_MODES).nullish(),
cert_dns_provider: nullableString,
cert_dns_env: nullableString,
});
const http = z.object({
ratio: nullableRatio,
type: z.literal("http"),
enable: nullableBool,
port: nullablePort,
security: z.enum(SECURITY.http).nullish(),
sni: nullableString,
allow_insecure: nullableBool,
fingerprint: nullableString,
cert_mode: z.enum(CERT_MODES).nullish(),
cert_dns_provider: nullableString,
cert_dns_env: nullableString,
});
const mieru = z.object({
ratio: nullableRatio,
type: z.literal("mieru"),
enable: nullableBool,
port: nullablePort,
multiplex: z.enum(multiplexLevels).nullish(),
transport: z.enum(TRANSPORTS.mieru).nullish(),
});
export const protocolApiScheme = z.discriminatedUnion("type", [
ss,
vmess,
vless,
trojan,
hysteria,
tuic,
anytls,
socks,
naive,
http,
mieru,
]);
export const formSchema = z.object({
name: z.string().min(1),
address: z.string().min(1),
country: z.string().optional(),
city: z.string().optional(),
protocols: z.array(protocolApiScheme),
});
@@ -0,0 +1,39 @@
import type { protocols } from "./constants";
export type FieldConfig = {
name: string;
type: "input" | "select" | "switch" | "number" | "textarea";
label: string;
placeholder?: string;
options?: readonly string[];
defaultValue?: any;
min?: number;
max?: number;
step?: number;
suffix?: string;
generate?: {
function?: () =>
| Promise<string | Record<string, string>>
| string
| Record<string, string>;
functions?: {
label: string;
function: () =>
| Promise<string | Record<string, string>>
| string
| Record<string, string>;
}[];
updateFields?: Record<string, string>;
};
condition?: (protocol: any, values: any) => boolean;
group?:
| "basic"
| "transport"
| "security"
| "reality"
| "obfs"
| "encryption";
gridSpan?: 1 | 2;
};
export type ProtocolType = (typeof protocols)[number];
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,4 @@
export { generateMLKEM768KeyPair } from "./mlkem768";
export { generateRealityShortId } from "./short-id";
export { generatePassword } from "./uid";
export { generateRealityKeyPair } from "./x25519";
@@ -0,0 +1,22 @@
import mlkem from "mlkem-wasm";
import { toB64Url } from "./util";
export async function generateMLKEM768KeyPair() {
const mlkemKeyPair = await mlkem.generateKey({ name: "ML-KEM-768" }, true, [
"encapsulateBits",
"decapsulateBits",
]);
const mlkemPublicKeyRaw = await mlkem.exportKey(
"raw-public",
mlkemKeyPair.publicKey
);
const mlkemPrivateKeyRaw = await mlkem.exportKey(
"raw-seed",
mlkemKeyPair.privateKey
);
return {
publicKey: toB64Url(new Uint8Array(mlkemPublicKeyRaw)),
privateKey: toB64Url(new Uint8Array(mlkemPrivateKeyRaw)),
};
}
@@ -0,0 +1,15 @@
/**
* Generate a short ID for Reality
* @returns A random hexadecimal string of length 2, 4, 6, 8, 10, 12, 14, or 16
*/
export function generateRealityShortId() {
const hex = "0123456789abcdef";
const lengths = [2, 4, 6, 8, 10, 12, 14, 16];
const idx = Math.floor(Math.random() * lengths.length);
const len = lengths[idx] ?? 16;
let out = "";
for (let i = 0; i < len; i++) {
out += hex.charAt(Math.floor(Math.random() * hex.length));
}
return out;
}
@@ -0,0 +1,11 @@
import { uid } from "radash";
/**
* Generate a random password
* @param length Length of the password
* @param charset Character set to use (defaults to alphanumeric)
* @returns Randomly generated password
*/
export function generatePassword(length = 16, charset?: string) {
return uid(length, charset).toLowerCase();
}
@@ -0,0 +1,6 @@
export function toB64Url(bytes: Uint8Array) {
return btoa(String.fromCharCode(...bytes))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "");
}
@@ -0,0 +1,11 @@
import { x25519 } from "@noble/curves/ed25519.js";
import { toB64Url } from "./util";
/**
* Generate a Reality key pair
* @returns An object containing the private and public keys in base64url format
*/
export function generateRealityKeyPair() {
const { secretKey, publicKey } = x25519.keygen();
return { privateKey: toB64Url(secretKey), publicKey: toB64Url(publicKey) };
}
+392
View File
@@ -0,0 +1,392 @@
"use client";
import { Badge } from "@workspace/ui/components/badge";
import { Button } from "@workspace/ui/components/button";
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
import {
ProTable,
type ProTableActions,
} from "@workspace/ui/composed/pro-table/pro-table";
import { cn } from "@workspace/ui/lib/utils";
import {
createServer,
deleteServer,
filterServerList,
resetSortWithServer,
updateServer,
} from "@workspace/ui/services/admin/server";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useNode } from "@/stores/node";
import { useServer } from "@/stores/server";
import DynamicMultiplier from "./dynamic-multiplier";
import OnlineUsersCell from "./online-users-cell";
import ServerConfig from "./server-config";
import ServerForm from "./server-form";
import ServerInstall from "./server-install";
function PctBar({ value }: { value: number }) {
const v = value.toFixed(2);
const widthClass =
value >= 90
? "w-[90%]"
: value >= 80
? "w-4/5"
: value >= 70
? "w-[70%]"
: value >= 60
? "w-3/5"
: value >= 50
? "w-1/2"
: value >= 40
? "w-2/5"
: value >= 30
? "w-[30%]"
: value >= 20
? "w-1/5"
: value >= 10
? "w-[10%]"
: "w-0";
return (
<div className="min-w-24">
<div className="text-xs leading-none">{v}%</div>
<div className="h-1.5 w-full rounded bg-muted">
<div className={cn("h-1.5 rounded bg-primary", widthClass)} />
</div>
</div>
);
}
function RegionIpCell({
country,
city,
ip,
notAvailableText,
}: {
country?: string;
city?: string;
ip?: string;
notAvailableText: string;
}) {
const region =
[country, city].filter(Boolean).join(" / ") || notAvailableText;
return (
<div className="flex items-center gap-1">
<Badge variant="outline">{region}</Badge>
<Badge variant="secondary">{ip || notAvailableText}</Badge>
</div>
);
}
export default function Servers() {
const { t } = useTranslation("servers");
const { isServerReferencedByNodes } = useNode();
const { fetchServers } = useServer();
const [loading, setLoading] = useState(false);
const ref = useRef<ProTableActions>(null);
return (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<DynamicMultiplier />
<ServerConfig />
</div>
<ProTable<API.Server, { search: string }>
action={ref}
actions={{
render: (row) => [
<ServerForm
initialValues={row}
key="edit"
loading={loading}
onSubmit={async (values) => {
setLoading(true);
try {
await updateServer({
id: row.id,
...(values as unknown as Omit<
API.UpdateServerRequest,
"id"
>),
});
toast.success(t("updated", "Updated"));
ref.current?.refresh();
fetchServers();
setLoading(false);
return true;
} catch {
setLoading(false);
return false;
}
}}
title={t("drawerEditTitle", "Edit Server")}
trigger={t("edit", "Edit")}
/>,
<ServerInstall key="install" server={row} />,
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description={t(
"confirmDeleteDesc",
"This action cannot be undone."
)}
key="delete"
onConfirm={async () => {
await deleteServer({ id: row.id } as API.DeleteServerRequest);
toast.success(t("deleted", "Deleted"));
ref.current?.refresh();
fetchServers();
}}
title={t("confirmDeleteTitle", "Delete this server?")}
trigger={
<Button
disabled={isServerReferencedByNodes(row.id)}
variant="destructive"
>
{t("delete", "Delete")}
</Button>
}
/>,
<Button
key="copy"
onClick={async () => {
setLoading(true);
const {
id: _id,
created_at: _created_at,
updated_at: _updated_at,
last_reported_at: _last_reported_at,
status: _status,
...others
} = row as Record<string, unknown>;
const body: API.CreateServerRequest = {
name: others.name as string,
country: others.country as string,
city: others.city as string,
address: others.address as string,
protocols: (others.protocols as API.Protocol[]) || [],
};
await createServer(body);
toast.success(t("copied", "Copied"));
ref.current?.refresh();
fetchServers();
setLoading(false);
}}
variant="outline"
>
{t("copy", "Copy")}
</Button>,
],
batchRender(rows) {
const hasReferencedServers = rows.some((row) =>
isServerReferencedByNodes(row.id)
);
return [
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description={t(
"confirmDeleteDesc",
"This action cannot be undone."
)}
key="delete"
onConfirm={async () => {
await Promise.all(
rows.map((r) => deleteServer({ id: r.id }))
);
toast.success(t("deleted", "Deleted"));
ref.current?.refresh();
fetchServers();
}}
title={t("confirmDeleteTitle", "Delete this server?")}
trigger={
<Button disabled={hasReferencedServers} variant="destructive">
{t("delete", "Delete")}
</Button>
}
/>,
];
},
}}
columns={[
{
accessorKey: "id",
header: t("id", "ID"),
cell: ({ row }) => <Badge>{row.getValue("id")}</Badge>,
},
{ accessorKey: "name", header: t("name", "Name") },
{
id: "region_ip",
header: t("address", "Address"),
cell: ({ row }) => (
<RegionIpCell
city={row.original.city as unknown as string}
country={row.original.country as unknown as string}
ip={row.original.address as unknown as string}
notAvailableText={t("notAvailable", "Not Available")}
/>
),
},
{
accessorKey: "protocols",
header: t("protocols", "Protocols"),
cell: ({ row }) => {
const list = row.original.protocols.filter(
(p) => p.enable
) as API.Protocol[];
if (!list.length) return "—";
return (
<div className="flex flex-col gap-1">
{list.map((p, idx) => {
const ratio = Number(p.ratio ?? 1) || 1;
return (
<div className="flex items-center gap-2" key={idx}>
<Badge variant="outline">{ratio.toFixed(2)}x</Badge>
<Badge variant="secondary">{p.type}</Badge>
<Badge variant="secondary">{p.port}</Badge>
</div>
);
})}
</div>
);
},
},
{
id: "status",
header: t("status", "Status"),
cell: ({ row }) => {
const offline = row.original.status.status === "offline";
return (
<div className="flex items-center gap-2">
<span
className={cn(
"inline-block h-2.5 w-2.5 rounded-full",
offline ? "bg-zinc-400" : "bg-emerald-500"
)}
/>
<span className="text-sm">
{offline ? t("offline", "Offline") : t("online", "Online")}
</span>
</div>
);
},
},
{
id: "cpu",
header: t("cpu", "CPU"),
cell: ({ row }) => (
<PctBar
value={(row.original.status?.cpu as unknown as number) ?? 0}
/>
),
},
{
id: "mem",
header: t("memory", "Memory"),
cell: ({ row }) => (
<PctBar
value={(row.original.status?.mem as unknown as number) ?? 0}
/>
),
},
{
id: "disk",
header: t("disk", "Disk"),
cell: ({ row }) => (
<PctBar
value={(row.original.status?.disk as unknown as number) ?? 0}
/>
),
},
{
id: "online_users",
header: t("onlineUsers", "Online Users"),
cell: ({ row }) => (
<OnlineUsersCell
status={row.original.status as API.ServerStatus}
/>
),
},
]}
header={{
title: t("pageTitle", "Servers"),
toolbar: (
<div className="flex gap-2">
<ServerForm
loading={loading}
onSubmit={async (values) => {
setLoading(true);
try {
await createServer(
values as unknown as API.CreateServerRequest
);
toast.success(t("created", "Created"));
ref.current?.refresh();
fetchServers();
setLoading(false);
return true;
} catch {
setLoading(false);
return false;
}
}}
title={t("drawerCreateTitle", "Create Server")}
trigger={t("create", "Create")}
/>
</div>
),
}}
onSort={async (source, target, items) => {
const sourceIndex = items.findIndex(
(item) => String(item.id) === source
);
const targetIndex = items.findIndex(
(item) => String(item.id) === target
);
const originalSorts = items.map((item) => item.sort);
const [movedItem] = items.splice(sourceIndex, 1);
items.splice(targetIndex, 0, movedItem!);
const updatedItems = items.map((item, index) => {
const originalSort = originalSorts[index];
const newSort =
originalSort !== undefined ? originalSort : item.sort;
return { ...item, sort: newSort };
});
const changedItems = updatedItems.filter(
(item, index) => item.sort !== items[index]?.sort
);
if (changedItems.length > 0) {
resetSortWithServer({
sort: changedItems.map((item) => ({
id: item.id,
sort: item.sort,
})) as API.SortItem[],
});
toast.success(t("sorted_success", "Sorted successfully"));
}
return updatedItems;
}}
params={[{ key: "search" }]}
request={async (pagination, filter) => {
const { data } = await filterServerList({
page: pagination.page,
size: pagination.size,
search: filter?.search || undefined,
});
const list = (data?.data?.list || []) as API.Server[];
const total = (data?.data?.total ?? list.length) as number;
return { list, total };
}}
/>
</div>
);
}
@@ -0,0 +1,221 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Badge } from "@workspace/ui/components/badge";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@workspace/ui/components/sheet";
import { ProTable } from "@workspace/ui/composed/pro-table/pro-table";
import { getUserSubscribeById } from "@workspace/ui/services/admin/user";
import { formatBytes } from "@workspace/ui/utils/formatting";
import { Users } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { IpLink } from "@/components/ip-link";
import { UserDetail } from "@/sections/user/user-detail";
import { formatDate } from "@/utils/common";
function UserSubscribeInfo({
subscribeId,
open,
type,
expiredText,
unlimitedText,
}: {
subscribeId: number;
open: boolean;
type:
| "account"
| "subscribeName"
| "subscribeId"
| "trafficUsage"
| "expireTime";
expiredText: string;
unlimitedText: string;
}) {
const { data } = useQuery({
enabled: subscribeId !== 0 && open,
queryKey: ["getUserSubscribeById", subscribeId],
queryFn: async () => {
const { data } = await getUserSubscribeById({ id: subscribeId });
return data.data;
},
});
if (!data) return <span className="text-muted-foreground">--</span>;
switch (type) {
case "account":
if (!data.user_id)
return <span className="text-muted-foreground">--</span>;
return <UserDetail id={data.user_id} />;
case "subscribeName":
if (!data.subscribe?.name)
return <span className="text-muted-foreground">--</span>;
return <span className="text-sm">{data.subscribe.name}</span>;
case "subscribeId":
if (!data.id) return <span className="text-muted-foreground">--</span>;
return <span className="font-mono text-sm">{data.id}</span>;
case "trafficUsage": {
const usedTraffic = data.upload + data.download;
const totalTraffic = data.traffic || 0;
return (
<div className="min-w-0 text-sm">
<div className="wrap-break-word">
{formatBytes(usedTraffic)} /{" "}
{totalTraffic > 0 ? formatBytes(totalTraffic) : unlimitedText}
</div>
</div>
);
}
case "expireTime": {
if (!data.expire_time)
return <span className="text-muted-foreground">--</span>;
const isExpired = data.expire_time < Date.now() / 1000;
return (
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-2">
<span className="text-sm">{formatDate(data.expire_time)}</span>
{isExpired && (
<Badge className="w-fit px-1 py-0 text-xs" variant="destructive">
{expiredText}
</Badge>
)}
</div>
);
}
default:
return <span className="text-muted-foreground">--</span>;
}
}
export default function OnlineUsersCell({
status,
}: {
status?: API.ServerStatus;
}) {
const { t } = useTranslation("servers");
const [open, setOpen] = useState(false);
return (
<Sheet onOpenChange={setOpen} open={open}>
<SheetTrigger asChild>
<button
className="flex items-center gap-2 bg-transparent p-0 text-muted-foreground text-sm hover:text-foreground"
type="button"
>
<Users className="h-4 w-4" /> {status?.online.length}
</button>
</SheetTrigger>
<SheetContent className="h-screen w-screen max-w-none sm:h-auto sm:w-[900px] sm:max-w-[90vw]">
<SheetHeader>
<SheetTitle>{t("onlineUsers", "Online Users")}</SheetTitle>
</SheetHeader>
<div className="h-[calc(100vh-48px-16px)] overflow-y-auto px-6 py-4 sm:h-[calc(100dvh-48px-16px-env(safe-area-inset-top))]">
<ProTable<API.ServerOnlineUser, Record<string, unknown>>
columns={[
{
accessorKey: "ip",
header: t("ipAddresses", "IP Addresses"),
cell: ({ row }) => {
const ips = row.original.ip;
return (
<div className="flex min-w-0 flex-col gap-1">
{ips.map((item) => (
<div
className="whitespace-nowrap text-sm"
key={`${item.protocol}-${item.ip}`}
>
<Badge>{item.protocol}</Badge>
<IpLink className="ml-1 font-medium" ip={item.ip} />
</div>
))}
</div>
);
},
},
{
accessorKey: "user",
header: t("user", "User"),
cell: ({ row }) => (
<UserSubscribeInfo
expiredText={t("expired", "Expired")}
open={open}
subscribeId={Number(row.original.subscribe_id)}
type="account"
unlimitedText={t("unlimited", "Unlimited")}
/>
),
},
{
accessorKey: "subscription",
header: t("subscription", "Subscription"),
cell: ({ row }) => (
<UserSubscribeInfo
expiredText={t("expired", "Expired")}
open={open}
subscribeId={Number(row.original.subscribe_id)}
type="subscribeName"
unlimitedText={t("unlimited", "Unlimited")}
/>
),
},
{
accessorKey: "subscribeId",
header: t("subscribeId", "Subscribe ID"),
cell: ({ row }) => (
<UserSubscribeInfo
expiredText={t("expired", "Expired")}
open={open}
subscribeId={Number(row.original.subscribe_id)}
type="subscribeId"
unlimitedText={t("unlimited", "Unlimited")}
/>
),
},
{
accessorKey: "traffic",
header: t("traffic", "Traffic"),
cell: ({ row }) => (
<UserSubscribeInfo
expiredText={t("expired", "Expired")}
open={open}
subscribeId={Number(row.original.subscribe_id)}
type="trafficUsage"
unlimitedText={t("unlimited", "Unlimited")}
/>
),
},
{
accessorKey: "expireTime",
header: t("expireTime", "Expire Time"),
cell: ({ row }) => (
<UserSubscribeInfo
expiredText={t("expired", "Expired")}
open={open}
subscribeId={Number(row.original.subscribe_id)}
type="expireTime"
unlimitedText={t("unlimited", "Unlimited")}
/>
),
},
]}
header={{ hidden: true }}
request={async () => ({
list: status?.online || [],
total: status?.online?.length || 0,
})}
/>
</div>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,652 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@workspace/ui/components/button";
import { Card, CardContent } from "@workspace/ui/components/card";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@workspace/ui/components/form";
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 { ArrayInput } from "@workspace/ui/composed/dynamic-Inputs";
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
import { Icon } from "@workspace/ui/composed/icon";
import {
getNodeConfig,
updateNodeConfig,
} from "@workspace/ui/services/admin/system";
import { unitConversion } from "@workspace/ui/utils/unit-conversions";
import { DicesIcon } from "lucide-react";
import { uid } from "radash";
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 { SS_CIPHERS } from "./form-schema";
const dnsConfigSchema = z.object({
proto: z.string(), // z.enum(['tcp', 'udp', 'tls', 'https', 'quic']),
address: z.string(),
domains: z.array(z.string()),
});
const outboundConfigSchema = z.object({
name: z.string(),
protocol: z.string(),
address: z.string(),
port: z.number(),
cipher: z.string().optional(),
password: z.string().optional(),
rules: z.array(z.string()).optional(),
});
const nodeConfigSchema = z.object({
node_secret: z.string().optional(),
node_pull_interval: z.number().optional(),
node_push_interval: z.number().optional(),
traffic_report_threshold: z.number().optional(),
ip_strategy: z.enum(["prefer_ipv4", "prefer_ipv6"]).optional(),
dns: z.array(dnsConfigSchema).optional(),
block: z.array(z.string()).optional(),
outbound: z.array(outboundConfigSchema).optional(),
});
type NodeConfigFormData = z.infer<typeof nodeConfigSchema>;
export default function ServerConfig() {
const { t } = useTranslation("servers");
const [open, setOpen] = useState(false);
const [saving, setSaving] = useState(false);
const { data: cfgResp, refetch: refetchCfg } = useQuery({
queryKey: ["getNodeConfig"],
queryFn: async () => {
const { data } = await getNodeConfig();
return data.data as API.NodeConfig | undefined;
},
enabled: open,
});
const form = useForm<NodeConfigFormData>({
resolver: zodResolver(nodeConfigSchema),
defaultValues: {
node_secret: "",
node_pull_interval: undefined,
node_push_interval: undefined,
traffic_report_threshold: undefined,
ip_strategy: "prefer_ipv4",
dns: [],
block: [],
outbound: [],
},
});
useEffect(() => {
if (cfgResp) {
form.reset({
node_secret: cfgResp.node_secret ?? "",
node_pull_interval: cfgResp.node_pull_interval as number | undefined,
node_push_interval: cfgResp.node_push_interval as number | undefined,
traffic_report_threshold: cfgResp.traffic_report_threshold as
| number
| undefined,
ip_strategy:
(cfgResp.ip_strategy as "prefer_ipv4" | "prefer_ipv6" | undefined) ||
"prefer_ipv4",
dns: cfgResp.dns || [],
block: cfgResp.block || [],
outbound: cfgResp.outbound || [],
});
}
}, [cfgResp, form]);
async function onSubmit(values: NodeConfigFormData) {
setSaving(true);
try {
await updateNodeConfig(values as API.NodeConfig);
toast.success(t("server_config.saveSuccess", "Saved successfully"));
await refetchCfg();
setOpen(false);
} finally {
setSaving(false);
}
}
return (
<Sheet onOpenChange={setOpen} open={open}>
<SheetTrigger asChild>
<Card>
<CardContent>
<div className="flex cursor-pointer items-center justify-between">
<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:resistor-nodes"
/>
</div>
<div className="flex-1">
<p className="font-medium">
{t("server_config.title", "Node configuration")}
</p>
<p className="truncate text-muted-foreground text-sm">
{t(
"server_config.description",
"Manage node communication keys, pull/push intervals."
)}
</p>
</div>
</div>
<Icon className="size-6" icon="mdi:chevron-right" />
</div>
</CardContent>
</Card>
</SheetTrigger>
<SheetContent className="w-[720px] max-w-full md:max-w-3xl">
<SheetHeader>
<SheetTitle>
{t("server_config.title", "Node configuration")}
</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))] px-6">
<Tabs className="pt-4" defaultValue="basic">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="basic">
{t("server_config.tabs.basic", "Basic Configuration")}
</TabsTrigger>
<TabsTrigger value="dns">
{t("server_config.tabs.dns", "DNS Configuration")}
</TabsTrigger>
<TabsTrigger value="outbound">
{t("server_config.tabs.outbound", "Outbound Rules")}
</TabsTrigger>
<TabsTrigger value="block">
{t("server_config.tabs.block", "Block Rules")}
</TabsTrigger>
</TabsList>
<Form {...form}>
<form
className="mt-4"
id="server-config-form"
onSubmit={form.handleSubmit(onSubmit)}
>
<TabsContent className="space-y-4" value="basic">
<FormField
control={form.control}
name="node_secret"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"server_config.fields.communication_key",
"Communication key"
)}
</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={field.onChange}
placeholder={t(
"server_config.fields.communication_key_placeholder",
"Please enter"
)}
suffix={
<div className="flex h-9 items-center bg-muted px-3">
<DicesIcon
className="cursor-pointer"
onClick={() => {
const id = uid(32).toLowerCase();
const formatted = `${id.slice(0, 8)}-${id.slice(8, 12)}-${id.slice(12, 16)}-${id.slice(16, 20)}-${id.slice(20)}`;
form.setValue("node_secret", formatted);
}}
/>
</div>
}
value={field.value || ""}
/>
</FormControl>
<FormDescription>
{t(
"server_config.fields.communication_key_desc",
"Used for node authentication."
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="node_pull_interval"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"server_config.fields.node_pull_interval",
"Node pull interval"
)}
</FormLabel>
<FormControl>
<EnhancedInput
min={0}
onValueChange={field.onChange}
placeholder={t(
"server_config.fields.communication_key_placeholder",
"Please enter"
)}
suffix="S"
type="number"
value={field.value as number | undefined}
/>
</FormControl>
<FormDescription>
{t(
"server_config.fields.node_pull_interval_desc",
"How often the node pulls configuration (seconds)."
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="node_push_interval"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"server_config.fields.node_push_interval",
"Node push interval"
)}
</FormLabel>
<FormControl>
<EnhancedInput
min={0}
onValueChange={field.onChange}
placeholder={t(
"server_config.fields.communication_key_placeholder",
"Please enter"
)}
step={0.1}
suffix="S"
type="number"
value={field.value as number | undefined}
/>
</FormControl>
<FormDescription>
{t(
"server_config.fields.node_push_interval_desc",
"How often the node pushes stats (seconds)."
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="traffic_report_threshold"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"server_config.fields.traffic_report_threshold",
"Traffic Report Threshold"
)}
</FormLabel>
<FormControl>
<EnhancedInput
min={0}
onValueChange={(value) => {
field.onChange(unitConversion("mbToBits", value));
}}
placeholder="1"
suffix="MB"
type="number"
value={unitConversion(
"bitsToMb",
field.value as number | undefined
)}
/>
</FormControl>
<FormDescription>
{t(
"server_config.fields.traffic_report_threshold_desc",
"Set the minimum threshold for traffic reporting."
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</TabsContent>
<TabsContent className="space-y-4" value="dns">
<FormField
control={form.control}
name="ip_strategy"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("server_config.fields.ip_strategy", "IP Strategy")}
</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={t(
"server_config.fields.ip_strategy_placeholder",
"Select IP strategy"
)}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="prefer_ipv4">
{t(
"server_config.fields.ip_strategy_ipv4",
"Prefer IPv4"
)}
</SelectItem>
<SelectItem value="prefer_ipv6">
{t(
"server_config.fields.ip_strategy_ipv6",
"Prefer IPv6"
)}
</SelectItem>
</SelectContent>
</Select>
<FormDescription>
{t(
"server_config.fields.ip_strategy_desc",
"Choose IP version preference for network connections"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="dns"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"server_config.fields.dns_config",
"DNS Configuration"
)}
</FormLabel>
<FormControl>
<ArrayInput
className="grid grid-cols-2 gap-2"
fields={[
{
name: "proto",
type: "select",
placeholder: t(
"server_config.fields.dns_proto_placeholder",
"Select type"
),
options: [
{ label: "TCP", value: "tcp" },
{ label: "UDP", value: "udp" },
{ label: "TLS", value: "tls" },
{ label: "HTTPS", value: "https" },
{ label: "QUIC", value: "quic" },
],
},
{
name: "address",
type: "text",
placeholder: "8.8.8.8:53",
},
{
name: "domains",
type: "textarea",
className: "col-span-2",
placeholder: t(
"server_config.fields.dns_domains_placeholder",
"One domain rule per line"
),
},
]}
onChange={(values) => {
const converted = values.map((item: any) => ({
proto: item.proto,
address: item.address,
domains:
typeof item.domains === "string"
? item.domains
.split("\n")
.map((d: string) => d.trim())
: item.domains || [],
}));
field.onChange(converted);
}}
value={(field.value || []).map((item) => ({
...item,
domains: Array.isArray(item.domains)
? item.domains.join("\n")
: "",
}))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TabsContent>
<TabsContent className="space-y-4" value="outbound">
<FormField
control={form.control}
name="outbound"
render={({ field }) => (
<FormItem>
<FormControl>
<ArrayInput
className="grid grid-cols-2 gap-2"
fields={[
{
name: "name",
type: "text",
className: "col-span-2",
placeholder: t(
"server_config.fields.outbound_name_placeholder",
"Configuration name"
),
},
{
name: "protocol",
type: "select",
placeholder: t(
"server_config.fields.outbound_protocol_placeholder",
"Select protocol"
),
options: [
{ label: "HTTP", value: "http" },
{ label: "SOCKS", value: "socks" },
{
label: "Shadowsocks",
value: "shadowsocks",
},
{ label: "Brook", value: "brook" },
{ label: "Snell", value: "snell" },
{ label: "VMess", value: "vmess" },
{ label: "VLESS", value: "vless" },
{ label: "Trojan", value: "trojan" },
{ label: "WireGuard", value: "wireguard" },
{ label: "Hysteria", value: "hysteria" },
{ label: "TUIC", value: "tuic" },
{ label: "AnyTLS", value: "anytls" },
{ label: "Naive", value: "naive" },
{ label: "Direct", value: "direct" },
{ label: "Reject", value: "reject" },
],
},
{
name: "cipher",
type: "select",
options: SS_CIPHERS.map((cipher) => ({
label: cipher,
value: cipher,
})),
visible: (item: Record<string, unknown>) =>
item.protocol === "shadowsocks",
},
{
name: "address",
type: "text",
placeholder: t(
"server_config.fields.outbound_address_placeholder",
"Server address"
),
},
{
name: "port",
type: "number",
placeholder: t(
"server_config.fields.outbound_port_placeholder",
"Port number"
),
},
{
name: "password",
type: "text",
placeholder: t(
"server_config.fields.outbound_password_placeholder",
"Password (optional)"
),
},
{
name: "rules",
type: "textarea",
className: "col-span-2",
placeholder: t(
"server_config.fields.outbound_rules_placeholder",
"One rule per line"
),
},
]}
onChange={(values) => {
const converted = values.map((item: any) => ({
name: item.name,
protocol: item.protocol,
address: item.address,
port: item.port,
cipher: item.cipher,
password: item.password,
rules:
typeof item.rules === "string"
? item.rules
.split("\n")
.map((r: string) => r.trim())
: item.rules || [],
}));
field.onChange(converted);
}}
value={(field.value || []).map((item) => ({
...item,
rules: Array.isArray(item.rules)
? item.rules.join("\n")
: "",
}))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TabsContent>
<TabsContent className="space-y-4" value="block">
<FormField
control={form.control}
name="block"
render={({ field }) => (
<FormItem>
<FormControl>
<Textarea
onChange={(e) => {
const lines = e.target.value
.split("\n")
.map((line) => line.trim());
field.onChange(lines);
}}
placeholder={t(
"server_config.fields.block_rules_placeholder",
"One domain rule per line"
)}
rows={10}
value={(field.value || []).join("\n")}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TabsContent>
</form>
</Form>
</Tabs>
</ScrollArea>
<SheetFooter className="flex-row justify-end gap-2 pt-3">
<Button
disabled={saving}
onClick={() => setOpen(false)}
variant="outline"
>
{t("actions.cancel", "Cancel")}
</Button>
<Button disabled={saving} form="server-config-form" type="submit">
<Icon
className={saving ? "mr-2 animate-spin" : "hidden"}
icon="mdi:loading"
/>
{t("actions.save", "Save")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,703 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@workspace/ui/components/accordion";
import { Badge } from "@workspace/ui/components/badge";
import { Button } from "@workspace/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@workspace/ui/components/form";
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 { Switch } from "@workspace/ui/components/switch";
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
import { Icon } from "@workspace/ui/composed/icon";
import { cn } from "@workspace/ui/lib/utils";
import { useEffect, useState } from "react";
import { useForm, useWatch } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useNode } from "@/stores/node";
import {
type FieldConfig,
formSchema,
getLabel,
getProtocolDefaultConfig,
protocols as PROTOCOLS,
useProtocolFields,
} from "./form-schema";
function DynamicField({
field,
control,
form,
protocolIndex,
protocolData,
}: {
field: FieldConfig;
control: any;
form: any;
protocolIndex: number;
protocolData: any;
}) {
const fieldName = `protocols.${protocolIndex}.${field.name}` as const;
if (field.condition && !field.condition(protocolData, {})) {
return null;
}
const commonProps = {
control,
name: fieldName,
};
switch (field.type) {
case "input":
return (
<FormField
{...commonProps}
render={({ field: fieldProps }) => (
<FormItem>
<FormLabel>{field.label}</FormLabel>
<FormControl>
<EnhancedInput
{...fieldProps}
onValueChange={(v) => fieldProps.onChange(v)}
placeholder={field.placeholder}
suffix={
field.generate ? (
field.generate.functions &&
field.generate.functions.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" type="button" variant="ghost">
<Icon className="h-4 w-4" icon="mdi:key" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{field.generate.functions.map((genFunc, idx) => (
<DropdownMenuItem
key={idx}
onClick={async () => {
const result = await genFunc.function();
if (typeof result === "string") {
fieldProps.onChange(result);
} else if (field.generate!.updateFields) {
Object.entries(
field.generate!.updateFields
).forEach(([fieldName, resultKey]) => {
const fullFieldName = `protocols.${protocolIndex}.${fieldName}`;
form.setValue(
fullFieldName,
(result as any)[resultKey]
);
});
} else if (result.privateKey) {
fieldProps.onChange(result.privateKey);
}
}}
>
{genFunc.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : field.generate.function ? (
<Button
onClick={async () => {
const result = await field.generate!.function!();
if (typeof result === "string") {
fieldProps.onChange(result);
} else if (field.generate!.updateFields) {
Object.entries(
field.generate!.updateFields
).forEach(([fieldName, resultKey]) => {
const fullFieldName = `protocols.${protocolIndex}.${fieldName}`;
form.setValue(
fullFieldName,
(result as any)[resultKey]
);
});
} else if (result.privateKey) {
fieldProps.onChange(result.privateKey);
}
}}
size="sm"
type="button"
variant="ghost"
>
<Icon className="h-4 w-4" icon="mdi:key" />
</Button>
) : null
) : (
field.suffix
)
}
type="text"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
case "number":
return (
<FormField
{...commonProps}
render={({ field: fieldProps }) => (
<FormItem>
<FormLabel>{field.label}</FormLabel>
<FormControl>
<EnhancedInput
{...fieldProps}
max={field.max}
min={field.min}
onValueChange={(v) => fieldProps.onChange(v)}
placeholder={field.placeholder}
step={field.step || 1}
suffix={field.suffix}
type="number"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
case "select":
if (!field.options || field.options.length <= 1) {
return null;
}
return (
<FormField
{...commonProps}
render={({ field: fieldProps }) => (
<FormItem>
<FormLabel>{field.label}</FormLabel>
<FormControl>
<Select
onValueChange={(v) => fieldProps.onChange(v)}
value={fieldProps.value ?? field.defaultValue}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{field.options?.map((option) => (
<SelectItem key={option} value={option}>
{getLabel(option)}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
case "switch":
return (
<FormField
{...commonProps}
render={({ field: fieldProps }) => (
<FormItem>
<FormLabel>{field.label}</FormLabel>
<FormControl>
<div className="pt-2">
<Switch
checked={!!fieldProps.value}
onCheckedChange={(checked) => fieldProps.onChange(checked)}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
case "textarea":
return (
<FormField
{...commonProps}
render={({ field: fieldProps }) => (
<FormItem className="col-span-2">
<FormLabel>{field.label}</FormLabel>
<FormControl>
<textarea
{...fieldProps}
className="flex min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
onChange={(e) => fieldProps.onChange(e.target.value)}
placeholder={field.placeholder}
value={fieldProps.value ?? ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
default:
return null;
}
}
function renderFieldsByGroup(
fields: FieldConfig[],
group: string,
control: any,
form: any,
protocolIndex: number,
protocolData: any
) {
const groupFields = fields.filter((field) => field.group === group);
if (groupFields.length === 0) return null;
return (
<div className="grid grid-cols-2 gap-4">
{groupFields.map((field) => (
<DynamicField
control={control}
field={field}
form={form}
key={field.name}
protocolData={protocolData}
protocolIndex={protocolIndex}
/>
))}
</div>
);
}
function renderGroupCard(
title: string,
fields: FieldConfig[],
group: string,
control: any,
form: any,
protocolIndex: number,
protocolData: any
) {
const groupFields = fields.filter((field) => field.group === group);
if (groupFields.length === 0) return null;
const visibleFields = groupFields.filter(
(field) => !field.condition || field.condition(protocolData, {})
);
if (visibleFields.length === 0) return null;
return (
<div className="relative">
<fieldset className="rounded-lg border border-border">
<legend className="ml-3 bg-background px-1 py-1 font-medium text-foreground text-sm">
{title}
</legend>
<div className="p-4 pt-2">
{renderFieldsByGroup(
fields,
group,
control,
form,
protocolIndex,
protocolData
)}
</div>
</fieldset>
</div>
);
}
export default function ServerForm(props: {
trigger: string;
title: string;
loading?: boolean;
initialValues?: Partial<API.Server>;
onSubmit: (values: Partial<API.Server>) => Promise<boolean> | boolean;
}) {
const { trigger, title, loading, initialValues, onSubmit } = props;
const { t } = useTranslation("servers");
const [open, setOpen] = useState(false);
const [accordionValue, setAccordionValue] = useState<string>();
const { isProtocolUsedInNodes } = useNode();
const PROTOCOL_FIELDS = useProtocolFields();
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
address: "",
country: "",
city: "",
protocols: [] as any[],
...initialValues,
},
});
const { control } = form;
const protocolsValues = useWatch({ control, name: "protocols" });
useEffect(() => {
if (initialValues) {
form.reset({
name: "",
address: "",
country: "",
city: "",
...initialValues,
protocols: PROTOCOLS.map((type) => {
const existingProtocol = initialValues.protocols?.find(
(p) => p.type === type
);
const defaultConfig = getProtocolDefaultConfig(type);
return existingProtocol
? { ...defaultConfig, ...existingProtocol }
: defaultConfig;
}),
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialValues]);
async function handleSubmit(values: Record<string, any>) {
const filteredProtocols = (values?.protocols || []).filter(
(protocol: any) => {
const port = Number(protocol?.port);
return protocol && Number.isFinite(port) && port > 0 && port <= 65_535;
}
);
const result = {
name: values.name,
country: values.country,
city: values.city,
address: values.address,
protocols: filteredProtocols,
};
const ok = await onSubmit(result);
if (ok) {
form.reset();
setOpen(false);
}
}
return (
<Sheet onOpenChange={setOpen} open={open}>
<SheetTrigger asChild>
<Button
onClick={() => {
if (!initialValues) {
const full = PROTOCOLS.map((t) => getProtocolDefaultConfig(t));
form.reset({
name: "",
address: "",
country: "",
city: "",
protocols: full,
});
}
setOpen(true);
}}
>
{trigger}
</Button>
</SheetTrigger>
<SheetContent className="w-[700px] max-w-full gap-0 md:max-w-3xl">
<SheetHeader>
<SheetTitle>{title}</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))]">
<Form {...form}>
<form className="grid grid-cols-1 gap-2 px-6 pt-4">
<div className="grid grid-cols-2 gap-2 md:grid-cols-4">
<FormField
control={control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("name", "Name")}</FormLabel>
<FormControl>
<EnhancedInput
{...field}
onValueChange={(v) => field.onChange(v)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="address"
render={({ field }) => (
<FormItem>
<FormLabel>{t("address", "Address")}</FormLabel>
<FormControl>
<EnhancedInput
{...field}
onValueChange={(v) => field.onChange(v)}
placeholder={t(
"address_placeholder",
"Server address"
)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="country"
render={({ field }) => (
<FormItem>
<FormLabel>{t("country", "Country")}</FormLabel>
<FormControl>
<EnhancedInput
{...field}
onValueChange={(v) => field.onChange(v)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="city"
render={({ field }) => (
<FormItem>
<FormLabel>{t("city", "City")}</FormLabel>
<FormControl>
<EnhancedInput
{...field}
onValueChange={(v) => field.onChange(v)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="my-3">
<h3 className="font-semibold text-foreground text-sm">
{t("protocol_configurations", "Protocol Configurations")}
</h3>
<p className="mt-1 text-muted-foreground text-xs">
{t(
"protocol_configurations_desc",
"Enable and configure the required protocol types"
)}
</p>
</div>
<Accordion
className="w-full space-y-3"
collapsible
onValueChange={setAccordionValue}
type="single"
value={accordionValue}
>
{PROTOCOLS.map((type) => {
const i = Math.max(0, PROTOCOLS.indexOf(type));
const current = (protocolsValues[i] || {}) as Record<
string,
any
>;
const isEnabled = current?.enable;
const fields = PROTOCOL_FIELDS[type] || [];
return (
<AccordionItem
className="mb-2 rounded-lg border"
key={type}
value={type}
>
<AccordionTrigger className="px-4 py-3 hover:no-underline">
<div className="flex w-full items-center justify-between">
<div className="flex flex-col items-start gap-1">
<div className="flex items-center gap-1">
<span className="font-medium capitalize">
{type}
</span>
{current.transport && (
<Badge className="text-xs" variant="secondary">
{current.transport.toUpperCase()}
</Badge>
)}
{current.security &&
current.security !== "none" && (
<Badge className="text-xs" variant="outline">
{current.security.toUpperCase()}
</Badge>
)}
{current.port && (
<Badge className="text-xs">
{current.port}
</Badge>
)}
</div>
<div className="flex items-center gap-1">
<span
className={cn(
"text-xs",
isEnabled
? "text-green-500"
: "text-muted-foreground"
)}
>
{isEnabled
? t("enabled", "Enabled")
: t("disabled", "Disabled")}
</span>
</div>
</div>
<Switch
checked={!!isEnabled}
className="mr-2"
disabled={Boolean(
initialValues?.id &&
isProtocolUsedInNodes(
initialValues?.id || 0,
type
) &&
isEnabled
)}
onCheckedChange={(checked) => {
form.setValue(`protocols.${i}.enable`, checked);
}}
onClick={(e) => e.stopPropagation()}
/>
</div>
</AccordionTrigger>
<AccordionContent className="px-4 pt-0 pb-4">
<div className="-mx-4 space-y-4 rounded-b-lg border-t px-4 pt-4">
{renderGroupCard(
t("basic", "Basic Configuration"),
fields,
"basic",
control,
form,
i,
current
)}
{renderGroupCard(
t("obfs", "Obfuscation"),
fields,
"obfs",
control,
form,
i,
current
)}
{renderGroupCard(
t("transport", "Transport"),
fields,
"transport",
control,
form,
i,
current
)}
{renderGroupCard(
t("security", "Security"),
fields,
"security",
control,
form,
i,
current
)}
{renderGroupCard(
t("reality", "Reality"),
fields,
"reality",
control,
form,
i,
current
)}
{renderGroupCard(
t("encryption", "Encryption"),
fields,
"encryption",
control,
form,
i,
current
)}
</div>
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>
</form>
</Form>
</ScrollArea>
<SheetFooter className="flex-row justify-end gap-2 pt-3">
<Button
disabled={loading}
onClick={() => setOpen(false)}
variant="outline"
>
{t("cancel", "Cancel")}
</Button>
<Button
disabled={loading}
onClick={form.handleSubmit(handleSubmit, (errors) => {
const key = Object.keys(errors)[0] as keyof typeof errors;
if (key) toast.error(String(errors[key]?.message));
return false;
})}
>
{loading && (
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
)}
{t("confirm", "Confirm")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,127 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@workspace/ui/components/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@workspace/ui/components/dialog";
import { Input } from "@workspace/ui/components/input";
import { Label } from "@workspace/ui/components/label";
import { getNodeConfig } from "@workspace/ui/services/admin/system";
import {
type ChangeEvent,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
type Props = {
server: API.Server;
};
export default function ServerInstall({ server }: Props) {
const { t } = useTranslation("servers");
const [open, setOpen] = useState(false);
const [domain, setDomain] = useState("");
const { data: cfgResp } = useQuery({
queryKey: ["getNodeConfig"],
queryFn: async () => {
const { data } = await getNodeConfig();
return data.data as API.NodeConfig | undefined;
},
enabled: open,
});
useEffect(() => {
if (open) {
const host = localStorage.getItem("API_HOST") ?? window.location.origin;
setDomain(host);
}
}, [open]);
const installCommand = useMemo(() => {
const secret = cfgResp?.node_secret ?? "";
return `wget -N https://raw.githubusercontent.com/perfect-panel/ppanel-node/master/scripts/install.sh && bash install.sh --api-host ${domain} --server-id ${server.id} --secret-key ${secret}`;
}, [domain, server.id, cfgResp?.node_secret]);
async function handleCopy() {
try {
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(installCommand);
} else {
// fallback for environments without clipboard API
const el = document.createElement("textarea");
el.value = installCommand;
document.body.appendChild(el);
el.select();
document.execCommand("copy");
document.body.removeChild(el);
}
toast.success(t("copied", "Copied"));
setOpen(false);
} catch {
toast.error(t("copyFailed", "Copy failed"));
}
}
const onDomainChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setDomain(e.target.value);
localStorage.setItem("API_HOST", e.target.value);
}, []);
return (
<Dialog onOpenChange={setOpen} open={open}>
<DialogTrigger asChild>
<Button variant="secondary">{t("connect", "Connect")}</Button>
</DialogTrigger>
<DialogContent className="w-[720px] max-w-full md:max-w-3xl">
<DialogHeader>
<DialogTitle>{t("oneClickInstall", "One-click Install")}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>{t("apiHost", "API Host")}</Label>
<div className="flex items-center gap-2">
<Input
onChange={onDomainChange}
placeholder={t("apiHostPlaceholder", "http(s)://example.com")}
value={domain}
/>
</div>
</div>
<div>
<Label>{t("installCommand", "Install command")}</Label>
<div className="flex flex-col gap-2">
<textarea
aria-label={t("installCommand", "Install command")}
className="min-h-[88px] w-full rounded border p-2 font-mono text-sm"
readOnly
value={installCommand}
/>
</div>
</div>
</div>
<DialogFooter className="flex-row justify-end gap-2 pt-3">
<Button onClick={() => setOpen(false)} variant="outline">
{t("close", "Close")}
</Button>
<Button onClick={handleCopy}>
{t("copyAndClose", "Copy and Close")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,327 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@workspace/ui/components/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@workspace/ui/components/form";
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 { Textarea } from "@workspace/ui/components/textarea";
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
import { Icon } from "@workspace/ui/composed/icon";
import {
getSubscribeConfig,
updateSubscribeConfig,
} from "@workspace/ui/services/admin/system";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { z } from "zod";
const subscribeConfigSchema = z.object({
single_model: z.boolean().optional(),
pan_domain: z.boolean().optional(),
subscribe_path: z.string().optional(),
subscribe_domain: z.string().optional(),
user_agent_limit: z.boolean().optional(),
user_agent_list: z.string().optional(),
});
type SubscribeConfigFormData = z.infer<typeof subscribeConfigSchema>;
export default function ConfigForm() {
const { t } = useTranslation("subscribe");
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const { data, refetch } = useQuery({
queryKey: ["getSubscribeConfig"],
queryFn: async () => {
const { data } = await getSubscribeConfig();
return data.data;
},
enabled: open,
});
const form = useForm<SubscribeConfigFormData>({
resolver: zodResolver(subscribeConfigSchema),
defaultValues: {
single_model: false,
pan_domain: false,
subscribe_path: "",
subscribe_domain: "",
user_agent_limit: false,
user_agent_list: "",
},
});
useEffect(() => {
if (data) {
form.reset(data);
}
}, [data, form]);
async function onSubmit(values: SubscribeConfigFormData) {
setLoading(true);
try {
await updateSubscribeConfig(values as API.SubscribeConfig);
toast.success(t("config.updateSuccess", "Settings updated successfully"));
refetch();
setOpen(false);
} catch (_error) {
toast.error(t("config.updateError", "Update failed"));
} 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:cog" />
</div>
<div className="flex-1">
<p className="font-medium">
{t("config.title", "Subscription Configuration")}
</p>
<p className="text-muted-foreground text-sm">
{t("config.description", "Manage subscription system settings")}
</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("config.title", "Subscription Configuration")}
</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="subscribe-config-form"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="single_model"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"config.singleSubscriptionMode",
"Single Subscription Mode"
)}
</FormLabel>
<FormControl>
<Switch
checked={field.value}
className="!mt-0 float-end"
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t(
"config.singleSubscriptionModeDescription",
"Limit users to one active subscription. Existing subscriptions unaffected"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="pan_domain"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("config.wildcardResolution", "Wildcard Resolution")}
</FormLabel>
<FormControl>
<Switch
checked={field.value}
className="!mt-0 float-end"
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t(
"config.wildcardResolutionDescription",
"Enable wildcard domain resolution for subscriptions"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="subscribe_path"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("config.subscriptionPath", "Subscription Path")}
</FormLabel>
<FormControl>
<EnhancedInput
onValueBlur={field.onChange}
placeholder={t(
"config.subscriptionPathPlaceholder",
"Enter subscription path"
)}
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"config.subscriptionPathDescription",
"Custom path for subscription endpoints (better performance after system restart)"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="subscribe_domain"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("config.subscriptionDomain", "Subscription Domain")}
</FormLabel>
<FormControl>
<Textarea
className="h-32"
placeholder={`${t(
"config.subscriptionDomainPlaceholder",
"Enter subscription domain, one per line"
)}\nexample.com\nwww.example.com`}
{...field}
/>
</FormControl>
<FormDescription>
{t(
"config.subscriptionDomainDescription",
"Custom domain for subscription links"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="user_agent_limit"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("config.userAgentLimit", "{userAgent} Restriction", {
userAgent: "User-Agent",
})}
</FormLabel>
<FormControl>
<Switch
checked={field.value}
className="!mt-0 float-end"
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t(
"config.userAgentLimitDescription",
"Enable access restrictions based on {userAgent}",
{
userAgent: "User-Agent",
}
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="user_agent_list"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("config.userAgentList", "{userAgent} Whitelist", {
userAgent: "User-Agent",
})}
</FormLabel>
<FormControl>
<Textarea
className="h-32"
placeholder={`${t(
"config.userAgentListPlaceholder",
"Enter allowed {userAgent}, one per line",
{ userAgent: "User-Agent" }
)}\nClashX\nClashForAndroid\nClash-verge`}
{...field}
/>
</FormControl>
<FormDescription>
{t(
"config.userAgentListDescription",
"Allowed {userAgent} for subscription access, one per line. Configured application {userAgent} will be automatically included",
{
userAgent: "User-Agent",
}
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<SheetFooter className="flex-row justify-end gap-2 pt-3">
<Button
disabled={loading}
onClick={() => setOpen(false)}
variant="outline"
>
{t("actions.cancel", "Cancel")}
</Button>
<Button disabled={loading} form="subscribe-config-form" type="submit">
{loading && (
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
)}
{t("actions.save", "Save")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,25 @@
"use client";
import { Card, CardContent } from "@workspace/ui/components/card";
import { useTranslation } from "react-i18next";
import ConfigForm from "./config-form";
import { ProtocolForm } from "./protocol-form";
export default function Subscribe() {
const { t } = useTranslation("subscribe");
return (
<div className="space-y-4">
<h2 className="font-semibold text-lg">
{t("config.title", "Subscription Configuration")}
</h2>
<Card className="py-3">
<CardContent>
<ConfigForm />
</CardContent>
</Card>
<ProtocolForm />
</div>
);
}
@@ -0,0 +1,938 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import type { ColumnDef } from "@tanstack/react-table";
import { Badge } from "@workspace/ui/components/badge";
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,
} from "@workspace/ui/components/sheet";
import { Switch } from "@workspace/ui/components/switch";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@workspace/ui/components/tabs";
import { Textarea } from "@workspace/ui/components/textarea";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@workspace/ui/components/tooltip";
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
import { GoTemplateEditor } from "@workspace/ui/composed/editor/index";
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
import { Icon } from "@workspace/ui/composed/icon";
import {
ProTable,
type ProTableActions,
} from "@workspace/ui/composed/pro-table/index";
import { UploadImage } from "@workspace/ui/composed/upload-image";
import {
createSubscribeApplication,
deleteSubscribeApplication,
getSubscribeApplicationList,
updateSubscribeApplication,
} from "@workspace/ui/services/admin/application";
import { useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { z } from "zod";
import { subscribeSchema } from "./schema";
import { TemplatePreview } from "./template-preview";
const createClientFormSchema = (t: any) =>
z.object({
name: z
.string()
.min(1, t("form.validation.nameRequired", "Client name is required")),
description: z.string().optional(),
icon: z.string().optional(),
user_agent: z
.string()
.min(
1,
`User-Agent ${t("form.validation.userAgentRequiredSuffix", "is required")}`
),
scheme: z.string().optional(),
template: z.string(),
output_format: z.string(),
download_link: z.object({
windows: z.string().optional(),
mac: z.string().optional(),
linux: z.string().optional(),
ios: z.string().optional(),
android: z.string().optional(),
harmony: z.string().optional(),
}),
});
type ClientFormData = z.infer<ReturnType<typeof createClientFormSchema>>;
export function ProtocolForm() {
const { t } = useTranslation("subscribe");
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const [editingClient, setEditingClient] =
useState<API.SubscribeApplication | null>(null);
const tableRef = useRef<ProTableActions>(null);
const clientFormSchema = createClientFormSchema(t);
const form = useForm<ClientFormData>({
resolver: zodResolver(clientFormSchema),
defaultValues: {
name: "",
description: "",
icon: "",
user_agent: "",
scheme: "",
template: "",
output_format: "",
download_link: {
windows: "",
mac: "",
linux: "",
ios: "",
android: "",
harmony: "",
},
},
});
const request = async (
pagination: { page: number; size: number },
_filter: Record<string, unknown>
) => {
const { data } = await getSubscribeApplicationList({
page: pagination.page,
size: pagination.size,
});
return {
list: data.data?.list || [],
total: data.data?.total || 0,
};
};
const columns: ColumnDef<API.SubscribeApplication, any>[] = [
{
accessorKey: "is_default",
header: t("table.columns.default", "Default"),
cell: ({ row }) => (
<Switch
checked={row.original.is_default}
onCheckedChange={async (checked) => {
await updateSubscribeApplication({
...row.original,
is_default: checked,
});
tableRef.current?.refresh();
}}
/>
),
},
{
accessorKey: "name",
header: t("table.columns.name", "Client Name"),
cell: ({ row }) => (
<div className="flex items-center gap-2">
{row.original.icon && (
<div className="relative h-6 w-6 flex-shrink-0">
<img
alt={row.original.name}
className="h-full w-full rounded object-contain"
height={24}
onError={() => {
console.log(`Failed to load image for ${row.original.name}`);
}}
src={row.original.icon}
width={24}
/>
</div>
)}
<span className="font-medium">{row.original.name}</span>
</div>
),
},
{
accessorKey: "user_agent",
header: "User-Agent",
cell: ({ row }) => (
<div className="max-w-[150px] truncate font-mono text-muted-foreground text-sm">
{row.original.user_agent}
</div>
),
},
{
accessorKey: "output_format",
header: t("table.columns.outputFormat", "Output Format"),
cell: ({ row }) => (
<Badge className="text-xs" variant="secondary">
{t(
`outputFormats.${row.original.output_format}`,
row.original.output_format
) || row.original.output_format}
</Badge>
),
},
{
accessorKey: "download_link",
header: t("table.columns.supportedPlatforms", "Supported Platforms"),
cell: ({ row }) => (
<div className="flex flex-wrap gap-1">
{Object.entries(row.original.download_link || {}).map(
([key, value]) => {
if (value) {
return (
<Badge className="text-xs" key={key} variant="secondary">
{t(`platforms.${key}`, key)}
</Badge>
);
}
return null;
}
)}
</div>
),
},
{
accessorKey: "description",
header: t("table.columns.description", "Description"),
cell: ({ row }) => (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<div className="max-w-[200px] truncate text-muted-foreground text-sm">
{row.original.description}
</div>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p>{row.original.description}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
),
},
];
const handleAdd = () => {
setEditingClient(null);
form.reset({
name: "",
description: "",
icon: "",
user_agent: "",
scheme: "",
template: "",
output_format: "",
download_link: {
windows: "",
mac: "",
linux: "",
ios: "",
android: "",
harmony: "",
},
});
setOpen(true);
};
const handleEdit = (client: API.SubscribeApplication) => {
setEditingClient(client);
form.reset({
...client,
download_link: client.download_link || {
windows: "",
mac: "",
linux: "",
ios: "",
android: "",
harmony: "",
},
});
setOpen(true);
};
const handleDelete = async (client: API.SubscribeApplication) => {
setLoading(true);
try {
await deleteSubscribeApplication({ id: client.id });
tableRef.current?.refresh();
toast.success(t("actions.deleteSuccess", "Deleted successfully"));
} catch (error) {
console.error("Failed to delete client:", error);
toast.error(t("actions.deleteFailed", "Delete failed"));
} finally {
setLoading(false);
}
};
const handleBatchDelete = async (clients: API.SubscribeApplication[]) => {
setLoading(true);
try {
await Promise.all(
clients.map((client) => deleteSubscribeApplication({ id: client.id }))
);
tableRef.current?.refresh();
toast.success(
t(
"actions.batchDeleteSuccess",
"Successfully deleted {count} clients",
{ count: clients.length }
)
);
} catch (error) {
console.error("Failed to batch delete clients:", error);
toast.error(t("actions.deleteFailed", "Delete failed"));
} finally {
setLoading(false);
}
};
const onSubmit = async (data: ClientFormData) => {
setLoading(true);
try {
if (editingClient) {
await updateSubscribeApplication({
...data,
is_default: editingClient.is_default,
id: editingClient.id,
});
toast.success(t("actions.updateSuccess", "Updated successfully"));
} else {
await createSubscribeApplication({
...data,
is_default: false,
});
toast.success(t("actions.createSuccess", "Created successfully"));
}
setOpen(false);
tableRef.current?.refresh();
} catch (error) {
console.error("Failed to save client:", error);
toast.error(t("actions.saveFailed", "Save failed"));
} finally {
setLoading(false);
}
};
return (
<>
<ProTable<API.SubscribeApplication, Record<string, unknown>>
action={tableRef}
actions={{
render: (row: API.SubscribeApplication) => [
<TemplatePreview
applicationId={row.id}
key="preview"
output_format={row.output_format}
/>,
<Button
key="edit"
onClick={() =>
handleEdit(row as unknown as API.SubscribeApplication)
}
>
{t("actions.edit", "Edit")}
</Button>,
<ConfirmButton
cancelText={t("actions.cancel", "Cancel")}
confirmText={t("actions.confirm", "Confirm")}
description={t(
"actions.deleteWarning",
"This operation cannot be undone. Are you sure you want to delete this client?"
)}
key="delete"
onConfirm={() =>
handleDelete(row as unknown as API.SubscribeApplication)
}
title={t("actions.confirmDelete", "Confirm Delete")}
trigger={
<Button disabled={loading} variant="destructive">
{t("actions.delete", "Delete")}
</Button>
}
/>,
],
batchRender: (rows: API.SubscribeApplication[]) => [
<ConfirmButton
cancelText={t("actions.cancel", "Cancel")}
confirmText={t("actions.confirm", "Confirm")}
description={t(
"actions.batchDeleteWarning",
"Are you sure you want to delete the selected {count} clients?",
{
count: rows.length,
}
)}
key="batchDelete"
onConfirm={() =>
handleBatchDelete(rows as unknown as API.SubscribeApplication[])
}
title={t("actions.confirmDelete", "Confirm Delete")}
trigger={
<Button variant="destructive">
{t("actions.batchDelete", "Batch Delete")}
</Button>
}
/>,
],
}}
columns={columns}
header={{
title: (
<div className="flex items-center justify-between">
<h2 className="font-semibold text-lg">
{t("protocol.title", "Client Management")}
</h2>
<a
className="inline-flex items-center gap-2 rounded-md px-3 py-1 font-medium text-primary text-sm hover:underline"
href="https://github.com/perfect-panel/subscription-template"
rel="noreferrer noopener"
target="_blank"
>
<Icon className="h-4 w-4" icon="mdi:github" />
<span>Template Repo</span>
<Icon
className="h-4 w-4 text-muted-foreground"
icon="mdi:open-in-new"
/>
</a>
</div>
),
toolbar: (
<Button onClick={handleAdd}>{t("actions.add", "Add")}</Button>
),
}}
request={request}
/>
<Sheet onOpenChange={setOpen} open={open}>
<SheetContent className="w-[580px] max-w-full md:max-w-screen-md">
<SheetHeader>
<SheetTitle>
{editingClient
? t("form.editTitle", "Edit Client")
: t("form.addTitle", "Add Client")}
</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100dvh-48px-36px-36px)] px-6">
<Form {...form}>
<form className="space-y-6 py-4">
<Tabs className="w-full" defaultValue="basic">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="basic">
{t("form.tabs.basic", "Basic Info")}
</TabsTrigger>
<TabsTrigger value="template">
{t("form.tabs.template", "Templates")}
</TabsTrigger>
<TabsTrigger value="download">
{t("form.tabs.download", "Downloads")}
</TabsTrigger>
</TabsList>
<TabsContent className="space-y-4" value="basic">
<FormField
control={form.control}
name="icon"
render={({ field }) => (
<FormItem>
<FormLabel>{t("form.fields.icon", "Icon")}</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={(value) => {
form.setValue(field.name, value as string);
}}
placeholder="https://example.com/icon.png"
suffix={
<UploadImage
className="h-9 rounded-none border-none bg-muted px-2"
onChange={(value) => {
form.setValue(field.name, value as string);
}}
/>
}
type="text"
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"form.descriptions.icon",
"Icon URL or base64 encoding"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("form.fields.name", "Name")}</FormLabel>
<FormControl>
<Input placeholder="Clash for Windows" {...field} />
</FormControl>
<FormDescription>
{t("form.descriptions.name", "Client display name")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="user_agent"
render={({ field }) => (
<FormItem>
<FormLabel>User-Agent</FormLabel>
<FormControl>
<Input placeholder="Clash" {...field} />
</FormControl>
<FormDescription>
{t(
"form.descriptions.userAgentPrefix",
"Client identifier for distinguishing different clients"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("form.fields.description", "Description")}
</FormLabel>
<FormControl>
<Textarea
placeholder={t(
"form.descriptions.description",
"Detailed client description"
)}
{...field}
/>
</FormControl>
<FormDescription>
{t(
"form.descriptions.description",
"Detailed client description"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</TabsContent>
<TabsContent className="space-y-4" value="template">
<FormField
control={form.control}
name="output_format"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("form.fields.outputFormat", "Output Format")}
</FormLabel>
<FormControl>
<Select
onValueChange={field.onChange}
value={field.value}
>
<SelectTrigger>
<SelectValue placeholder="Select ..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="base64">
{t("outputFormats.base64", "Base64")}
</SelectItem>
<SelectItem value="yaml">
{t("outputFormats.yaml", "YAML")}
</SelectItem>
<SelectItem value="json">
{t("outputFormats.json", "JSON")}
</SelectItem>
<SelectItem value="conf">
{t("outputFormats.conf", "CONF")}
</SelectItem>
<SelectItem value="plain">
{t("outputFormats.plain", "Plain Text")}
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>
{t(
"form.descriptions.outputFormat",
"Subscription configuration file format"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="scheme"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-2">
{t("form.fields.scheme", "URL Scheme")}
<TooltipProvider>
<Tooltip>
<TooltipTrigger
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<Icon
className="h-4 w-4 text-muted-foreground"
icon="mdi:help-circle-outline"
/>
</TooltipTrigger>
<TooltipContent
className="max-w-md bg-secondary text-secondary-foreground"
side="right"
>
<div className="space-y-2 text-sm">
<div className="font-medium">
{t(
"form.descriptions.scheme.title",
"URL Scheme template"
)}
</div>
<div>
<div className="font-medium">
{t(
"form.descriptions.scheme.variables",
"Supports variables:"
)}
</div>
<ul className="ml-2 list-disc space-y-1 text-xs">
<li>
<code className="rounded px-1">
{"${url}"}
</code>{" "}
-{" "}
{t(
"form.descriptions.scheme.urlVariable",
"subscription URL"
)}
</li>
<li>
<code className="rounded px-1">
{"${name}"}
</code>{" "}
-{" "}
{t(
"form.descriptions.scheme.nameVariable",
"site name"
)}
</li>
</ul>
</div>
<div>
<div className="font-medium">
{t(
"form.descriptions.scheme.functions",
"Supports functions:"
)}
</div>
<ul className="ml-2 list-disc space-y-1 text-xs">
<li>
<code className="rounded px-1">
{"${encodeURIComponent(...)}"}
</code>{" "}
-{" "}
{t(
"form.descriptions.scheme.urlEncoding",
"URL encoding"
)}
</li>
<li>
<code className="rounded px-1">
{"${window.btoa(...)}"}
</code>{" "}
-{" "}
{t(
"form.descriptions.scheme.base64Encoding",
"Base64 encoding"
)}
</li>
<li>
<code className="rounded px-1">
{"${JSON.stringify(...)}"}
</code>{" "}
-{" "}
{t(
"form.descriptions.scheme.jsonStringify",
"JSON object to string"
)}
</li>
</ul>
</div>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</FormLabel>
<FormControl>
<Input
placeholder="clash://install-config?url=${url}&name=${name}"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="template"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-2">
{t(
"form.fields.template",
"Subscription File Template"
)}
<TooltipProvider>
<Tooltip>
<TooltipTrigger
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<Icon
className="h-4 w-4 text-muted-foreground"
icon="mdi:help-circle-outline"
/>
</TooltipTrigger>
<TooltipContent
className="max-w-md bg-secondary text-secondary-foreground"
side="right"
>
<div className="space-y-2 text-sm">
<div className="font-medium">
{t(
"form.descriptions.template.title",
"Go Template Syntax"
)}
</div>
<div>
<div className="font-medium">
{t(
"form.descriptions.template.variables",
"Available variables:"
)}
</div>
<ul className="ml-2 list-disc space-y-1 text-xs">
<li>
<code className="rounded px-1">
.SiteName
</code>{" "}
-{" "}
{t(
"form.descriptions.template.siteName",
"site name"
)}
</li>
<li>
<code className="rounded px-1">
.SubscribeName
</code>{" "}
-{" "}
{t(
"form.descriptions.template.subscribeName",
"subscription name"
)}
</li>
<li>
<code className="rounded px-1">
.Proxies
</code>{" "}
-{" "}
{t(
"form.descriptions.template.nodes",
"proxy nodes list"
)}
</li>
<li>
<code className="rounded px-1">
.UserInfo
</code>{" "}
-{" "}
{t(
"form.descriptions.template.userInfo",
"user info (traffic, expiry, etc.)"
)}
</li>
</ul>
</div>
<div>
<div className="font-medium">
{t(
"form.descriptions.template.functions",
"Template functions:"
)}
</div>
<ul className="ml-2 list-disc space-y-1 text-xs">
<li>
<code className="rounded px-1">
{"{{range .Proxies}}...{{end}}"}
</code>{" "}
-{" "}
{t(
"form.descriptions.template.range",
"iterate arrays"
)}
</li>
<li>
<code className="rounded px-1">
{"{{if .condition}}...{{end}}"}
</code>{" "}
-{" "}
{t(
"form.descriptions.template.if",
"conditional statements"
)}
</li>
<li>
<code className="rounded px-1">
{"{{sprig_func}}"}
</code>{" "}
-{" "}
{t(
"form.descriptions.template.sprig",
"Sprig function library (string processing, dates, etc.)"
)}
</li>
</ul>
</div>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</FormLabel>
<FormControl>
<GoTemplateEditor
enableSprig
onChange={(value: string | undefined) =>
field.onChange(value || "")
}
schema={subscribeSchema}
showLineNumbers
value={field.value || ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TabsContent>
<TabsContent className="space-y-4" value="download">
<div className="space-y-4">
<div className="grid gap-3">
{[
"windows",
"mac",
"linux",
"ios",
"android",
"harmony",
].map((key) => (
<FormField
control={form.control}
key={key}
name={
`download_link.${key}` as `download_link.${keyof ClientFormData["download_link"]}`
}
render={({ field }) => (
<FormItem>
<FormLabel>
{t(`platforms.${key}`, key)}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(`platforms.${key}`, key)}{" "}
{t(
"form.descriptions.downloadLink",
"platform download URL"
)}
</FormDescription>
</FormItem>
)}
/>
))}
</div>
</div>
</TabsContent>
</Tabs>
</form>
</Form>
</ScrollArea>
<SheetFooter className="flex-row justify-end gap-2 pt-3">
<Button onClick={() => setOpen(false)} variant="outline">
{t("actions.cancel", "Cancel")}
</Button>
<Button disabled={loading} onClick={form.handleSubmit(onSubmit)}>
{loading && (
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
)}
{editingClient
? t("actions.update", "Update")
: t("actions.add", "Add")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
</>
);
}
+228
View File
@@ -0,0 +1,228 @@
export const subscribeSchema = {
SiteName: { type: "string", description: "Site name" },
SubscribeName: { type: "string", description: "Subscribe name" },
Proxies: {
type: "array",
description: "Array of proxy nodes",
items: {
type: "object",
properties: {
Name: { type: "string", description: "Node name" },
Server: { type: "string", description: "Server host" },
Port: { type: "number", description: "Server port" },
Type: { type: "string", description: "Proxy type" },
Tags: {
type: "array",
description: "Node tags",
items: { type: "string" },
},
Sort: { type: "number", description: "Node sort order" },
// Security Options
Security: {
type: "string",
description: "Security protocol",
},
SNI: {
type: "string",
description: "Server Name Indication for TLS",
},
AllowInsecure: {
type: "boolean",
description:
"Allow insecure connections (skip certificate verification)",
},
Fingerprint: {
type: "string",
description: "Client fingerprint for TLS connections",
},
RealityServerAddr: {
type: "string",
description: "Reality server address",
},
RealityServerPort: {
type: "number",
description: "Reality server port",
},
RealityPrivateKey: {
type: "string",
description: "Reality private key for authentication",
},
RealityPublicKey: {
type: "string",
description: "Reality public key for authentication",
},
RealityShortId: {
type: "string",
description: "Reality short ID for authentication",
},
// Transport Options
Transport: {
type: "string",
description: "Transport protocol (e.g., ws, http, grpc)",
},
Host: {
type: "string",
description: "For WebSocket/HTTP/HTTPS",
},
Path: { type: "string", description: "For HTTP/HTTPS" },
ServiceName: {
type: "string",
description: "For gRPC",
},
// Shadowsocks Options
Method: { type: "string", description: "Encryption method" },
ServerKey: {
type: "string",
description: "For Shadowsocks 2022",
},
// Vmess/Vless/Trojan Options
Flow: {
type: "string",
description: "Flow for Vmess/Vless/Trojan",
},
// Hysteria2 Options
HopPorts: {
type: "string",
description: "Comma-separated list of hop ports",
},
HopInterval: {
type: "number",
description: "Interval for hop ports in seconds",
},
ObfsPassword: {
type: "string",
description: "Obfuscation password for Hysteria2",
},
// Tuic Options
DisableSNI: {
type: "boolean",
description: "Disable SNI",
},
ReduceRtt: {
type: "boolean",
description: "Reduce RTT",
},
UDPRelayMode: {
type: "string",
description: 'UDP relay mode (e.g., "full", "partial")',
},
CongestionController: {
type: "string",
description: 'Congestion controller (e.g., "cubic", "bbr")',
},
// Hysteria2 additional options
UpMbps: {
type: "number",
description: "Upload bandwidth in Mbps",
},
DownMbps: {
type: "number",
description: "Download bandwidth in Mbps",
},
// VLESS encryption options
Encryption: {
type: "string",
description: "Encryption type for VLESS",
},
EncryptionMode: {
type: "string",
description: 'Encryption mode (e.g., "native", "xorpub", "random")',
},
EncryptionRtt: {
type: "string",
description: 'Encryption RTT (e.g., "0rtt", "1rtt")',
},
EncryptionTicket: {
type: "string",
description: "Encryption ticket",
},
EncryptionServerPadding: {
type: "string",
description: "Server padding for encryption",
},
EncryptionClientPadding: {
type: "string",
description: "Client padding for encryption",
},
EncryptionPassword: {
type: "string",
description: "Encryption password",
},
EncryptionPrivateKey: {
type: "string",
description: "Private key for encryption",
},
// XHTTP options
XhttpMode: {
type: "string",
description:
'XHTTP mode (e.g., "auto", "packet-up", "stream-up", "stream-one")',
},
XhttpExtra: {
type: "string",
description: "XHTTP extra parameters",
},
// Shadowsocks obfs options (combined with Hysteria2 obfs)
ObfsHost: {
type: "string",
description: "Obfuscation host",
},
ObfsPath: {
type: "string",
description: "Obfuscation path",
},
// Shadowsocks cipher
Cipher: {
type: "string",
description: "Shadowsocks cipher method",
},
// AnyTLS options
PaddingScheme: {
type: "string",
description: "Padding scheme for AnyTLS",
},
// Mieru options
Multiplex: {
type: "string",
description:
'Multiplex level (e.g., "none", "low", "middle", "high")',
},
// General protocol field
Enable: {
type: "boolean",
description: "Whether this protocol is enabled",
},
// UUID for vmess/vless
UUID: {
type: "string",
description: "User UUID for vmess/vless protocols",
},
// Alternative ID for vmess
AlterId: {
type: "number",
description: "Alternative ID for vmess (deprecated)",
},
// Password for trojan/tuic
Password: {
type: "string",
description: "Password for authentication",
},
},
},
},
UserInfo: {
type: "object",
description: "User information",
properties: {
Password: { type: "string", description: "User password" },
ExpiredAt: { type: "string", description: "Expiration date" },
Download: { type: "number", description: "Downloaded bytes" },
Upload: { type: "number", description: "Uploaded bytes" },
Traffic: { type: "number", description: "Total traffic bytes" },
SubscribeURL: {
type: "string",
description: "Subscription URL",
},
},
},
};
@@ -0,0 +1,118 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@workspace/ui/components/button";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@workspace/ui/components/sheet";
import { MonacoEditor } from "@workspace/ui/composed/editor/monaco-editor";
import { Icon } from "@workspace/ui/composed/icon";
import { previewSubscribeTemplate } from "@workspace/ui/services/admin/application";
import { useState } from "react";
import { useTranslation } from "react-i18next";
interface TemplatePreviewProps {
applicationId: number;
output_format?: string;
}
export function TemplatePreview({
applicationId,
output_format,
}: TemplatePreviewProps) {
const { t } = useTranslation("subscribe");
const [isOpen, setIsOpen] = useState(false);
const { data, isLoading, error } = useQuery({
queryKey: ["previewSubscribeTemplate", applicationId],
queryFn: () =>
previewSubscribeTemplate(
{ id: applicationId },
{ skipErrorHandler: true }
),
enabled: isOpen && !!applicationId,
retry: false,
});
const originalContent = data?.data?.data?.template || "";
const errorMessage =
(error as any)?.data?.msg ||
error?.message ||
t("templatePreview.failed", "Failed to load template");
const getDecodedContent = () => {
if (output_format === "base64" && originalContent) {
try {
return atob(originalContent);
} catch {
return t("templatePreview.base64.decodeError", "Base64 decode error");
}
}
return "";
};
const getDisplayContent = () => {
if (error) return errorMessage;
if (!originalContent) return "";
switch (output_format) {
case "base64": {
const decoded = getDecodedContent();
return `${t("templatePreview.base64.originalContent", "Original Content")}:\n${originalContent}\n\n${t("templatePreview.base64.decodedContent", "Decoded Content")}:\n${decoded}`;
}
default:
return originalContent;
}
};
const mapLanguage = (fmt?: string) => {
switch (fmt) {
case "json":
return "json";
case "yaml":
return "yaml";
case "base64":
return "ini";
case "plain":
return "ini";
case "conf":
return "ini";
default:
return "ini";
}
};
return (
<Sheet onOpenChange={setIsOpen} open={isOpen}>
<SheetTrigger asChild>
<Button variant="ghost">
<Icon className="h-4 w-4" icon="mdi:eye" />
{t("templatePreview.preview", "Preview")}
</Button>
</SheetTrigger>
<SheetHeader>
<SheetTitle />
</SheetHeader>
<SheetContent className="w-[800px] max-w-[90vw] pt-10 md:max-w-screen-md">
{isLoading ? (
<div className="flex items-center justify-center">
<Icon className="h-6 w-6 animate-spin" icon="mdi:loading" />
<span className="ml-2">
{t("templatePreview.loading", "Loading...")}
</span>
</div>
) : (
<MonacoEditor
language={mapLanguage(output_format)}
readOnly
showLineNumbers
title={t("templatePreview.title", "Template Preview")}
value={getDisplayContent()}
/>
)}
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,232 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@workspace/ui/components/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@workspace/ui/components/form";
import { ScrollArea } from "@workspace/ui/components/scroll-area";
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@workspace/ui/components/sheet";
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
import { Icon } from "@workspace/ui/composed/icon";
import {
getCurrencyConfig,
updateCurrencyConfig,
} from "@workspace/ui/services/admin/system";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { z } from "zod";
// Constants
const EXCHANGE_RATE_HOST_URL = "https://exchangerate.host";
const currencySchema = z.object({
access_key: z.string().optional(),
currency_unit: z.string().min(1),
currency_symbol: z.string().min(1),
});
type CurrencyFormData = z.infer<typeof currencySchema>;
export default function CurrencyConfig() {
const { t } = useTranslation("system");
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const { data, refetch } = useQuery({
queryKey: ["getCurrencyConfig"],
queryFn: async () => {
const { data } = await getCurrencyConfig();
return data.data;
},
enabled: open, // Only request data when the modal is open
});
const form = useForm<CurrencyFormData>({
resolver: zodResolver(currencySchema),
defaultValues: {
access_key: "",
currency_unit: "USD",
currency_symbol: "$",
},
});
useEffect(() => {
if (data) {
form.reset(data);
}
}, [data, form]);
async function onSubmit(values: CurrencyFormData) {
setLoading(true);
try {
await updateCurrencyConfig(values as API.CurrencyConfig);
toast.success(t("common.saveSuccess", "Save Successful"));
refetch();
setOpen(false);
} catch (_error) {
toast.error(t("common.saveFailed", "Save Failed"));
} 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:currency-usd" />
</div>
<div className="flex-1">
<p className="font-medium">
{t("currency.title", "Currency Configuration")}
</p>
<p className="text-muted-foreground text-sm">
{t(
"currency.description",
"Configure currency units, symbols, and exchange rate API settings"
)}
</p>
</div>
</div>
<Icon className="size-6" icon="mdi:chevron-right" />
</div>
</SheetTrigger>
<SheetContent className="w-[600px] max-w-full gap-0 md:max-w-screen-md">
<SheetHeader>
<SheetTitle>
{t("currency.title", "Currency Configuration")}
</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-24px-env(safe-area-inset-top))] px-6">
<Form {...form}>
<form
className="space-y-2 pt-4"
id="currency-form"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="access_key"
render={({ field }) => (
<FormItem>
<FormLabel>{t("currency.accessKey", "API Key")}</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={field.onChange}
placeholder={t(
"currency.accessKeyPlaceholder",
"Enter API key"
)}
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"currency.accessKeyDescription",
"Free exchange rate API key provided by {{url}}",
{
url: EXCHANGE_RATE_HOST_URL,
}
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="currency_unit"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("currency.currencyUnit", "Currency Unit")}
</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={field.onChange}
placeholder={t(
"currency.currencyUnitPlaceholder",
"USD"
)}
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"currency.currencyUnitDescription",
"Used for display purposes only; changing this will affect all currency units in the system"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="currency_symbol"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("currency.currencySymbol", "Currency Symbol")}
</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={field.onChange}
placeholder={t(
"currency.currencySymbolPlaceholder",
"$"
)}
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"currency.currencySymbolDescription",
"Used for display purposes only; changing this will affect all currency units in the system"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<SheetFooter className="flex-row justify-end gap-2 pt-3">
<Button
disabled={loading}
onClick={() => setOpen(false)}
variant="outline"
>
{t("common.cancel", "Cancel")}
</Button>
<Button disabled={loading} form="currency-form" type="submit">
{loading && (
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
)}
{t("common.save", "Save Settings")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,165 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@workspace/ui/components/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@workspace/ui/components/form";
import { ScrollArea } from "@workspace/ui/components/scroll-area";
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@workspace/ui/components/sheet";
import { MarkdownEditor } from "@workspace/ui/composed/editor/index";
import { Icon } from "@workspace/ui/composed/icon";
import {
getPrivacyPolicyConfig,
updatePrivacyPolicyConfig,
} from "@workspace/ui/services/admin/system";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { z } from "zod";
const privacyPolicySchema = z.object({
privacy_policy: z.string().optional(),
});
type PrivacyPolicyFormData = z.infer<typeof privacyPolicySchema>;
export default function PrivacyPolicyConfig() {
const { t } = useTranslation("system");
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const { data, refetch } = useQuery({
queryKey: ["getPrivacyPolicyConfig"],
queryFn: async () => {
const { data } = await getPrivacyPolicyConfig();
return data.data;
},
enabled: open,
});
const form = useForm<PrivacyPolicyFormData>({
resolver: zodResolver(privacyPolicySchema),
defaultValues: {
privacy_policy: "",
},
});
useEffect(() => {
if (data) {
form.reset({
privacy_policy: data.privacy_policy || "",
});
}
}, [data, form]);
async function onSubmit(values: PrivacyPolicyFormData) {
setLoading(true);
try {
await updatePrivacyPolicyConfig(values as API.PrivacyPolicyConfig);
toast.success(t("common.saveSuccess", "Save Successful"));
refetch();
setOpen(false);
} catch (_error) {
toast.error(t("common.saveFailed", "Save Failed"));
} 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:shield-account-outline"
/>
</div>
<div className="flex-1">
<p className="font-medium">
{t("privacyPolicy.title", "Privacy Policy")}
</p>
<p className="text-muted-foreground text-sm">
{t(
"privacyPolicy.description",
"Edit and manage privacy policy content"
)}
</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("privacyPolicy.title", "Privacy Policy")}</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-24px-env(safe-area-inset-top))] px-6">
<Form {...form}>
<form
className="space-y-2 pt-4"
id="privacy-policy-form"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="privacy_policy"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("privacyPolicy.title", "Privacy Policy")}
</FormLabel>
<FormControl>
<MarkdownEditor
onChange={field.onChange}
value={field.value || ""}
/>
</FormControl>
<FormDescription>
{t(
"privacyPolicy.description",
"Edit and manage privacy policy content"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<SheetFooter className="flex-row justify-end gap-2 pt-3">
<Button
disabled={loading}
onClick={() => setOpen(false)}
variant="outline"
>
{t("common.cancel", "Cancel")}
</Button>
<Button disabled={loading} form="privacy-policy-form" type="submit">
{loading && (
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
)}
{t("common.save", "Save Settings")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,376 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@workspace/ui/components/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@workspace/ui/components/form";
import { ScrollArea } from "@workspace/ui/components/scroll-area";
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@workspace/ui/components/sheet";
import { Textarea } from "@workspace/ui/components/textarea";
import { JSONEditor } from "@workspace/ui/composed/editor/json";
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
import { Icon } from "@workspace/ui/composed/icon";
import { UploadImage } from "@workspace/ui/composed/upload-image";
import {
getSiteConfig,
updateSiteConfig,
} from "@workspace/ui/services/admin/system";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { z } from "zod";
const siteSchema = z.object({
site_logo: z.string().optional(),
site_name: z.string().min(1),
site_desc: z.string().optional(),
keywords: z.string().optional(),
custom_html: z.string().optional(),
host: z.string().optional(),
custom_data: z.any().optional(),
});
type SiteFormData = z.infer<typeof siteSchema>;
export default function SiteConfig() {
const { t } = useTranslation("system");
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const { data, refetch } = useQuery({
queryKey: ["getSiteConfig"],
queryFn: async () => {
const { data } = await getSiteConfig();
return data.data;
},
enabled: open,
});
const form = useForm<SiteFormData>({
resolver: zodResolver(siteSchema),
defaultValues: {
site_logo: "",
site_name: "",
site_desc: "",
keywords: "",
custom_html: "",
host: "",
custom_data: {},
},
});
useEffect(() => {
if (data) {
form.reset(data);
}
}, [data, form]);
async function onSubmit(values: SiteFormData) {
setLoading(true);
try {
await updateSiteConfig(values as API.SiteConfig);
toast.success(t("common.saveSuccess", "Save Successful"));
refetch();
setOpen(false);
} catch (_error) {
toast.error(t("common.saveFailed", "Save Failed"));
} 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:web" />
</div>
<div className="flex-1">
<p className="font-medium">
{t("site.title", "Site Configuration")}
</p>
<p className="text-muted-foreground text-sm">
{t(
"site.description",
"Configure basic site information, logo, domain and other settings"
)}
</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("site.title", "Site Configuration")}</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-24px-env(safe-area-inset-top))] px-6">
<Form {...form}>
<form
className="space-y-2 pt-4"
id="site-form"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="site_logo"
render={({ field }) => (
<FormItem>
<FormLabel>{t("site.logo", "Site Logo")}</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={field.onChange}
placeholder={t(
"site.logoPlaceholder",
"Enter the URL of the logo, without ending with '/'"
)}
suffix={
<UploadImage
className="h-9 rounded-none border-none bg-muted px-2"
onChange={(value) => {
field.onChange(value);
}}
/>
}
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"site.logoDescription",
"Used for displaying the logo in designated locations"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="site_name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("site.siteName", "Site Name")}</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={field.onChange}
placeholder={t(
"site.siteNamePlaceholder",
"Enter site name"
)}
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"site.siteNameDescription",
"Used for displaying the site name in designated locations"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="site_desc"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("site.siteDesc", "Site Description")}
</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={field.onChange}
placeholder={t(
"site.siteDescPlaceholder",
"Enter site description"
)}
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"site.siteDescDescription",
"Used for displaying the site description in designated locations"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="keywords"
render={({ field }) => (
<FormItem>
<FormLabel>{t("site.keywords", "Keywords")}</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={field.onChange}
placeholder={t(
"site.keywordsPlaceholder",
"keyword1, keyword2, keyword3"
)}
value={field.value}
/>
</FormControl>
<FormDescription>
{t("site.keywordsDescription", "Used for SEO purposes")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="custom_html"
render={({ field }) => (
<FormItem>
<FormLabel>{t("site.customHtml", "Custom HTML")}</FormLabel>
<FormControl>
<Textarea
className="h-32"
placeholder={t(
"site.customHtmlDescription",
"Custom HTML code to be injected into the bottom of the site's body tag"
)}
{...field}
/>
</FormControl>
<FormDescription>
{t(
"site.customHtmlDescription",
"Custom HTML code to be injected into the bottom of the site's body tag"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="host"
render={({ field }) => (
<FormItem>
<FormLabel>{t("site.siteDomain", "Site Domain")}</FormLabel>
<FormControl>
<Textarea
className="h-32"
placeholder={`${t("site.siteDomainPlaceholder", "Please enter the domain address. For multiple domains, please enter one per line.")}\nexample.com\nwww.example.com`}
{...field}
/>
</FormControl>
<FormDescription>
{t(
"site.siteDomainDescription",
"Domain address of the current website, e.g., used in emails"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="custom_data"
render={({ field }) => (
<FormItem>
<FormLabel>{t("site.customData", "Custom Data")}</FormLabel>
<FormControl>
<JSONEditor
onBlur={(value) => field.onChange(value)}
schema={{
type: "object",
additionalProperties: true,
properties: {
website: { type: "string", title: "Website" },
contacts: {
type: "object",
title: "Contacts",
additionalProperties: true,
properties: {
email: { type: "string", title: "Email" },
telephone: {
type: "string",
title: "Telephone",
},
address: { type: "string", title: "Address" },
},
},
community: {
type: "object",
title: "Community",
additionalProperties: true,
properties: {
telegram: { type: "string", title: "Telegram" },
twitter: { type: "string", title: "Twitter" },
discord: { type: "string", title: "Discord" },
instagram: {
type: "string",
title: "Instagram",
},
linkedin: { type: "string", title: "Linkedin" },
facebook: { type: "string", title: "Facebook" },
github: { type: "string", title: "Github" },
},
},
},
}}
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"site.customDataDescription",
"Custom data for website customization"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<SheetFooter className="flex-row justify-end gap-2 pt-3">
<Button
disabled={loading}
onClick={() => setOpen(false)}
variant="outline"
>
{t("common.cancel", "Cancel")}
</Button>
<Button disabled={loading} form="site-form" type="submit">
{loading && (
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
)}
{t("common.save", "Save Settings")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,163 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@workspace/ui/components/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@workspace/ui/components/form";
import { ScrollArea } from "@workspace/ui/components/scroll-area";
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@workspace/ui/components/sheet";
import { MarkdownEditor } from "@workspace/ui/composed/editor/markdown";
import { Icon } from "@workspace/ui/composed/icon";
import {
getTosConfig,
updateTosConfig,
} from "@workspace/ui/services/admin/system";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { z } from "zod";
const tosSchema = z.object({
tos_content: z.string().optional(),
});
type TosFormData = z.infer<typeof tosSchema>;
export default function TosConfig() {
const { t } = useTranslation("system");
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const { data, refetch } = useQuery({
queryKey: ["getTosConfig"],
queryFn: async () => {
const { data } = await getTosConfig();
return data.data;
},
enabled: open,
});
const form = useForm<TosFormData>({
resolver: zodResolver(tosSchema),
defaultValues: {
tos_content: "",
},
});
useEffect(() => {
if (data) {
form.reset({
tos_content: data.tos_content || "",
});
}
}, [data, form]);
async function onSubmit(values: TosFormData) {
setLoading(true);
try {
await updateTosConfig(values as API.TosConfig);
toast.success(t("common.saveSuccess", "Save Successful"));
refetch();
setOpen(false);
} catch (_error) {
toast.error(t("common.saveFailed", "Save Failed"));
} 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:file-document-outline"
/>
</div>
<div className="flex-1">
<p className="font-medium">
{t("tos.title", "Terms of Service")}
</p>
<p className="text-muted-foreground text-sm">
{t(
"tos.description",
"Edit and manage terms of service content"
)}
</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("tos.title", "Terms of Service")}</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-24px-env(safe-area-inset-top))] px-6">
<Form {...form}>
<form
className="space-y-2 pt-4"
id="tos-form"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="tos_content"
render={({ field }) => (
<FormItem>
<FormLabel>{t("tos.title", "Terms of Service")}</FormLabel>
<FormControl>
<MarkdownEditor
onChange={field.onChange}
value={field.value || ""}
/>
</FormControl>
<FormDescription>
{t(
"tos.description",
"Edit and manage terms of service content"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<SheetFooter className="flex-row justify-end gap-2 pt-3">
<Button
disabled={loading}
onClick={() => setOpen(false)}
variant="outline"
>
{t("common.cancel", "Cancel")}
</Button>
<Button disabled={loading} form="tos-form" type="submit">
{loading && (
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
)}
{t("common.save", "Save Settings")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
+69
View File
@@ -0,0 +1,69 @@
import {
Table,
TableBody,
TableCell,
TableRow,
} from "@workspace/ui/components/table";
import { useTranslation } from "react-i18next";
import CurrencyForm from "./basic-settings/currency-form";
import PrivacyPolicyForm from "./basic-settings/privacy-policy-form";
import SiteForm from "./basic-settings/site-form";
import TosForm from "./basic-settings/tos-form";
import LogCleanupForm from "./log-cleanup/log-cleanup-form";
import InviteForm from "./user-security/invite-form";
import RegisterForm from "./user-security/register-form";
import VerifyCodeForm from "./user-security/verify-code-form";
import VerifyForm from "./user-security/verify-form";
export default function System() {
const { t } = useTranslation("system");
const formSections = [
{
title: t("basicSettings", "Basic Settings"),
forms: [
{ component: SiteForm },
{ component: CurrencyForm },
{ component: TosForm },
{ component: PrivacyPolicyForm },
],
},
{
title: t("userSecuritySettings", "User & Security"),
forms: [
{ component: RegisterForm },
{ component: InviteForm },
{ component: VerifyForm },
{ component: VerifyCodeForm },
],
},
{
title: t("logSettings", "Log Settings"),
forms: [{ component: LogCleanupForm }],
},
];
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,197 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@workspace/ui/components/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@workspace/ui/components/form";
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 { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
import { Icon } from "@workspace/ui/composed/icon";
import {
getLogSetting,
updateLogSetting,
} from "@workspace/ui/services/admin/log";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { z } from "zod";
const logCleanupSchema = z.object({
auto_clear: z.boolean(),
clear_days: z.number().min(1),
});
type LogCleanupFormData = z.infer<typeof logCleanupSchema>;
export default function LogCleanupForm() {
const { t } = useTranslation("system");
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const { data, refetch } = useQuery({
queryKey: ["getLogSetting"],
queryFn: async () => {
const { data } = await getLogSetting();
return data.data;
},
enabled: open,
});
const form = useForm<LogCleanupFormData>({
resolver: zodResolver(logCleanupSchema),
defaultValues: {
auto_clear: false,
clear_days: 30,
},
});
useEffect(() => {
if (data) {
form.reset(data);
}
}, [data, form]);
async function onSubmit(values: LogCleanupFormData) {
setLoading(true);
try {
await updateLogSetting(values as API.LogSetting);
toast.success(t("common.saveSuccess", "Save Successful"));
refetch();
setOpen(false);
} catch (_error) {
toast.error(t("common.saveFailed", "Save Failed"));
} 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:delete-sweep" />
</div>
<div className="flex-1">
<p className="font-medium">
{t("logCleanup.title", "Log Cleanup Settings")}
</p>
<p className="text-muted-foreground text-sm">
{t(
"logCleanup.description",
"Configure automatic log cleanup rules and retention period"
)}
</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("logCleanup.title", "Log Cleanup Settings")}
</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-24px-env(safe-area-inset-top))] px-6">
<Form {...form}>
<form
className="space-y-2 pt-4"
id="log-cleanup-form"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="auto_clear"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("logCleanup.autoClear", "Enable Auto Cleanup")}
</FormLabel>
<FormControl>
<Switch
checked={field.value}
className="!mt-0 float-end"
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t(
"logCleanup.autoClearDescription",
"When enabled, the system will automatically clear expired log records"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="clear_days"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("logCleanup.clearDays", "Retention Days")}
</FormLabel>
<FormControl>
<EnhancedInput
disabled={!form.watch("auto_clear")}
onValueChange={(value) => field.onChange(Number(value))}
placeholder={t(
"logCleanup.clearDaysPlaceholder",
"Enter retention days"
)}
type="number"
value={field.value?.toString()}
/>
</FormControl>
<FormDescription>
{t(
"logCleanup.clearDaysDescription",
"Number of days to retain logs; logs older than this will be cleaned up"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<SheetFooter className="flex-row justify-end gap-2 pt-3">
<Button
disabled={loading}
onClick={() => setOpen(false)}
variant="outline"
>
{t("common.cancel", "Cancel")}
</Button>
<Button disabled={loading} form="log-cleanup-form" type="submit">
{loading && (
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
)}
{t("common.save", "Save Settings")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,237 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@workspace/ui/components/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@workspace/ui/components/form";
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 { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
import { Icon } from "@workspace/ui/composed/icon";
import {
getInviteConfig,
updateInviteConfig,
} from "@workspace/ui/services/admin/system";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { z } from "zod";
const inviteSchema = z.object({
forced_invite: z.boolean().optional(),
referral_percentage: z.number().optional(),
only_first_purchase: z.boolean().optional(),
});
type InviteFormData = z.infer<typeof inviteSchema>;
export default function InviteConfig() {
const { t } = useTranslation("system");
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const { data, refetch } = useQuery({
queryKey: ["getInviteConfig"],
queryFn: async () => {
const { data } = await getInviteConfig();
return data.data;
},
enabled: open,
});
const form = useForm<InviteFormData>({
resolver: zodResolver(inviteSchema),
defaultValues: {
forced_invite: false,
referral_percentage: 0,
only_first_purchase: false,
},
});
useEffect(() => {
if (data) {
form.reset(data);
}
}, [data, form]);
async function onSubmit(values: InviteFormData) {
setLoading(true);
try {
await updateInviteConfig(values as API.InviteConfig);
toast.success(t("invite.saveSuccess", "Save Successful"));
refetch();
setOpen(false);
} catch (_error) {
toast.error(t("invite.saveFailed", "Save Failed"));
} 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:account-multiple-plus-outline"
/>
</div>
<div className="flex-1">
<p className="font-medium">
{t("invite.title", "Invitation Settings")}
</p>
<p className="text-muted-foreground text-sm">
{t(
"invite.description",
"Configure user invitation and referral reward settings"
)}
</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("invite.title", "Invitation Settings")}</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-24px-env(safe-area-inset-top))] px-6">
<Form {...form}>
<form
className="space-y-2 pt-4"
id="invite-form"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="forced_invite"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"invite.forcedInvite",
"Require Invitation to Register"
)}
</FormLabel>
<FormControl>
<Switch
checked={field.value}
className="!mt-0 float-end"
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t(
"invite.forcedInviteDescription",
"When enabled, users must register through an invitation link"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="referral_percentage"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"invite.referralPercentage",
"Referral Reward Percentage"
)}
</FormLabel>
<FormControl>
<EnhancedInput
max={100}
min={0}
onValueBlur={(value) => field.onChange(Number(value))}
placeholder={t(
"invite.inputPlaceholder",
"Please enter"
)}
suffix="%"
type="number"
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"invite.referralPercentageDescription",
"Percentage of reward given to referrers"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="only_first_purchase"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"invite.onlyFirstPurchase",
"First Purchase Reward Only"
)}
</FormLabel>
<FormControl>
<Switch
checked={field.value}
className="!mt-0 float-end"
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t(
"invite.onlyFirstPurchaseDescription",
"When enabled, referrers only receive rewards for the first purchase by referred users"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<SheetFooter className="flex-row justify-end gap-2 pt-3">
<Button
disabled={loading}
onClick={() => setOpen(false)}
variant="outline"
>
{t("common.cancel", "Cancel")}
</Button>
<Button disabled={loading} form="invite-form" type="submit">
{loading && (
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
)}
{t("common.save", "Save Settings")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,431 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@workspace/ui/components/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@workspace/ui/components/form";
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 {
getRegisterConfig,
updateRegisterConfig,
} from "@workspace/ui/services/admin/system";
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 { useSubscribe } from "@/stores/subscribe";
const registerSchema = z.object({
stop_register: z.boolean().optional(),
enable_trial: z.boolean().optional(),
trial_subscribe: z.number().optional(),
trial_time: z.number().optional(),
trial_time_unit: z.string().optional(),
enable_ip_register_limit: z.boolean().optional(),
ip_register_limit: z.number().optional(),
ip_register_limit_duration: z.number().optional(),
});
type RegisterFormData = z.infer<typeof registerSchema>;
export default function RegisterConfig() {
const { t } = useTranslation("system");
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const { data, refetch } = useQuery({
queryKey: ["getRegisterConfig"],
queryFn: async () => {
const { data } = await getRegisterConfig();
return data.data;
},
enabled: open,
});
const { subscribes } = useSubscribe();
const form = useForm<RegisterFormData>({
resolver: zodResolver(registerSchema),
defaultValues: {
stop_register: false,
enable_trial: false,
trial_subscribe: undefined,
trial_time: 0,
trial_time_unit: "day",
enable_ip_register_limit: false,
ip_register_limit: 1,
ip_register_limit_duration: 1,
},
});
useEffect(() => {
if (data) {
form.reset(data);
}
}, [data, form]);
async function onSubmit(values: RegisterFormData) {
setLoading(true);
try {
await updateRegisterConfig(values as API.RegisterConfig);
toast.success(t("register.saveSuccess", "Save Successful"));
refetch();
setOpen(false);
} catch (_error) {
toast.error(t("register.saveFailed", "Save Failed"));
} 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:account-plus-outline"
/>
</div>
<div className="flex-1">
<p className="font-medium">
{t("register.title", "Registration Settings")}
</p>
<p className="text-muted-foreground text-sm">
{t(
"register.description",
"Configure user registration related settings"
)}
</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("register.title", "Registration Settings")}
</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-24px-env(safe-area-inset-top))] px-6">
<Form {...form}>
<form
className="space-y-2 pt-4"
id="register-form"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="stop_register"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"register.stopNewUserRegistration",
"Stop New User Registration"
)}
</FormLabel>
<FormControl>
<Switch
checked={field.value}
className="!mt-0 float-end"
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t(
"register.stopNewUserRegistrationDescription",
"When enabled, new user registration will be disabled"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="enable_ip_register_limit"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"register.ipRegistrationLimit",
"IP Registration Limit"
)}
</FormLabel>
<FormControl>
<Switch
checked={field.value}
className="!mt-0 float-end"
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t(
"register.ipRegistrationLimitDescription",
"Limit the number of registrations from a single IP address"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{form.watch("enable_ip_register_limit") && (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="ip_register_limit"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"register.registrationLimitCount",
"Registration Limit Count"
)}
</FormLabel>
<FormControl>
<EnhancedInput
min={1}
onValueBlur={(value) =>
field.onChange(Number(value))
}
placeholder={t(
"register.inputPlaceholder",
"Please enter"
)}
type="number"
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"register.registrationLimitCountDescription",
"Number of registrations allowed per IP within the limit period"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="ip_register_limit_duration"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"register.registrationLimitExpire",
"Limit Period"
)}
</FormLabel>
<FormControl>
<EnhancedInput
min={1}
onValueBlur={(value) =>
field.onChange(Number(value))
}
placeholder={t(
"register.inputPlaceholder",
"Please enter"
)}
suffix={t("register.minute", "Minute(s)")}
type="number"
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"register.registrationLimitExpireDescription",
"Duration for IP registration limit (minutes)"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
<FormField
control={form.control}
name="enable_trial"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("register.enableTrial", "Enable Trial")}
</FormLabel>
<FormControl>
<Switch
checked={field.value}
className="!mt-0 float-end"
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t(
"register.enableTrialDescription",
"When enabled, new users will receive a trial subscription upon registration"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{form.watch("enable_trial") && (
<FormField
control={form.control}
name="trial_time"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("register.trialConfig", "Trial Configuration")}
</FormLabel>
<FormControl>
<div className="flex gap-2">
<EnhancedInput
className="flex-1"
min={0}
onValueBlur={(value) =>
field.onChange(Number(value))
}
placeholder={t(
"register.inputPlaceholder",
"Please enter"
)}
prefix={
<FormField
control={form.control}
name="trial_subscribe"
render={({ field }) => (
<Combobox
className="w-32 rounded-r-none bg-secondary"
onChange={(value: number) => {
if (value) {
field.onChange(value);
}
}}
options={subscribes?.map((item) => ({
label: item.name!,
value: item.id!,
}))}
placeholder={t(
"register.selectPlaceholder",
"Please select"
)}
value={field.value}
/>
)}
/>
}
suffix={
<FormField
control={form.control}
name="trial_time_unit"
render={({ field: unitField }) => (
<Combobox
className="w-32 rounded-l-none bg-secondary"
onChange={(value: string) => {
unitField.onChange(value);
}}
options={[
{
label: t("register.none", "None"),
value: "None",
},
{
label: t("register.year", "Year(s)"),
value: "Year",
},
{
label: t("register.month", "Month(s)"),
value: "Month",
},
{
label: t("register.day", "Day(s)"),
value: "Day",
},
{
label: t("register.hour", "Hour(s)"),
value: "Hour",
},
{
label: t(
"register.minute",
"Minute(s)"
),
value: "Minute",
},
]}
placeholder={t(
"register.selectPlaceholder",
"Please select"
)}
value={unitField.value}
/>
)}
/>
}
type="number"
value={field.value}
/>
</div>
</FormControl>
<FormDescription>
{t(
"register.trialConfigDescription",
"Configure trial subscription, duration and time unit for new users upon registration"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</form>
</Form>
</ScrollArea>
<SheetFooter className="flex-row justify-end gap-2 pt-3">
<Button
disabled={loading}
onClick={() => setOpen(false)}
variant="outline"
>
{t("common.cancel", "Cancel")}
</Button>
<Button disabled={loading} form="register-form" type="submit">
{loading && (
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
)}
{t("common.save", "Save Settings")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,240 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@workspace/ui/components/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@workspace/ui/components/form";
import { ScrollArea } from "@workspace/ui/components/scroll-area";
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@workspace/ui/components/sheet";
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
import { Icon } from "@workspace/ui/composed/icon";
import {
getVerifyCodeConfig,
updateVerifyCodeConfig,
} from "@workspace/ui/services/admin/system";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { z } from "zod";
const verifyCodeSchema = z.object({
verify_code_expire_time: z.number().optional(),
verify_code_interval: z.number().optional(),
verify_code_limit: z.number().optional(),
});
type VerifyCodeFormData = z.infer<typeof verifyCodeSchema>;
export default function VerifyCodeConfig() {
const { t } = useTranslation("system");
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const { data, refetch } = useQuery({
queryKey: ["getVerifyCodeConfig"],
queryFn: async () => {
const { data } = await getVerifyCodeConfig();
return data.data;
},
enabled: open,
});
const form = useForm<VerifyCodeFormData>({
resolver: zodResolver(verifyCodeSchema),
defaultValues: {
verify_code_expire_time: 300,
verify_code_interval: 60,
verify_code_limit: 10,
},
});
useEffect(() => {
if (data) {
form.reset(data);
}
}, [data, form]);
async function onSubmit(values: VerifyCodeFormData) {
setLoading(true);
try {
await updateVerifyCodeConfig(values as API.VerifyCodeConfig);
toast.success(t("verifyCode.saveSuccess", "Save Successful"));
refetch();
setOpen(false);
} catch (_error) {
toast.error(t("verifyCode.saveFailed", "Save Failed"));
} 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:message-text-clock-outline"
/>
</div>
<div className="flex-1">
<p className="font-medium">
{t("verifyCode.title", "Verification Code Settings")}
</p>
<p className="text-muted-foreground text-sm">
{t(
"verifyCode.description",
"Configure email verification code sending rules and limits"
)}
</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("verifyCode.title", "Verification Code Settings")}
</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-24px-env(safe-area-inset-top))] px-6">
<Form {...form}>
<form
className="space-y-2 pt-4"
id="verify-code-form"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="verify_code_expire_time"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("verifyCode.expireTime", "Verification Code Validity")}
</FormLabel>
<FormControl>
<EnhancedInput
min={60}
onValueBlur={(value) => field.onChange(Number(value))}
placeholder={t(
"verifyCode.inputPlaceholder",
"Please enter"
)}
suffix={t("verifyCode.seconds", "seconds")}
type="number"
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"verifyCode.expireTimeDescription",
"Validity period of verification codes (seconds)"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="verify_code_interval"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("verifyCode.interval", "Sending Interval")}
</FormLabel>
<FormControl>
<EnhancedInput
min={30}
onValueBlur={(value) => field.onChange(Number(value))}
placeholder={t(
"verifyCode.inputPlaceholder",
"Please enter"
)}
suffix={t("verifyCode.seconds", "seconds")}
type="number"
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"verifyCode.intervalDescription",
"Minimum interval between two verification code sends (seconds)"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="verify_code_limit"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("verifyCode.dailyLimit", "Daily Sending Limit")}
</FormLabel>
<FormControl>
<EnhancedInput
min={1}
onValueBlur={(value) => field.onChange(Number(value))}
placeholder={t(
"verifyCode.inputPlaceholder",
"Please enter"
)}
suffix={t("verifyCode.times", "time(s)")}
type="number"
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"verifyCode.dailyLimitDescription",
"Maximum number of verification codes each user can send per day"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<SheetFooter className="flex-row justify-end gap-2 pt-3">
<Button
disabled={loading}
onClick={() => setOpen(false)}
variant="outline"
>
{t("common.cancel", "Cancel")}
</Button>
<Button disabled={loading} form="verify-code-form" type="submit">
{loading && (
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
)}
{t("common.save", "Save Settings")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,293 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@workspace/ui/components/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@workspace/ui/components/form";
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 { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
import { Icon } from "@workspace/ui/composed/icon";
import {
getVerifyConfig,
updateVerifyConfig,
} from "@workspace/ui/services/admin/system";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { z } from "zod";
const verifySchema = z.object({
turnstile_site_key: z.string().optional(),
turnstile_secret: z.string().optional(),
enable_register_verify: z.boolean().optional(),
enable_login_verify: z.boolean().optional(),
enable_password_verify: z.boolean().optional(),
});
type VerifyFormData = z.infer<typeof verifySchema>;
export default function VerifyConfig() {
const { t } = useTranslation("system");
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const { data, refetch } = useQuery({
queryKey: ["getVerifyConfig"],
queryFn: async () => {
const { data } = await getVerifyConfig();
return data.data;
},
enabled: open,
});
const form = useForm<VerifyFormData>({
resolver: zodResolver(verifySchema),
defaultValues: {
turnstile_site_key: "",
turnstile_secret: "",
enable_register_verify: false,
enable_login_verify: false,
enable_password_verify: false,
},
});
useEffect(() => {
if (data) {
form.reset(data);
}
}, [data, form]);
async function onSubmit(values: VerifyFormData) {
setLoading(true);
try {
await updateVerifyConfig(values as API.VerifyConfig);
toast.success(t("verify.saveSuccess", "Save Successful"));
refetch();
setOpen(false);
} catch (_error) {
toast.error(t("verify.saveFailed", "Save Failed"));
} 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:shield-check-outline"
/>
</div>
<div className="flex-1">
<p className="font-medium">
{t("verify.title", "Security Verification")}
</p>
<p className="text-muted-foreground text-sm">
{t(
"verify.description",
"Configure Turnstile CAPTCHA and verification settings"
)}
</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("verify.title", "Security Verification")}</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-24px-env(safe-area-inset-top))] px-6">
<Form {...form}>
<form
className="space-y-2 pt-4"
id="verify-form"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="turnstile_site_key"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("verify.turnstileSiteKey", "Turnstile Site Key")}
</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={field.onChange}
placeholder={t(
"verify.turnstileSiteKeyPlaceholder",
"Enter Turnstile site key"
)}
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"verify.turnstileSiteKeyDescription",
"Cloudflare Turnstile site key for frontend verification"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="turnstile_secret"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("verify.turnstileSecret", "Turnstile Secret Key")}
</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={field.onChange}
placeholder={t(
"verify.turnstileSecretPlaceholder",
"Enter Turnstile secret key"
)}
type="password"
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"verify.turnstileSecretDescription",
"Cloudflare Turnstile secret key for backend verification"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="enable_register_verify"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"verify.enableRegisterVerify",
"Enable Verification on Registration"
)}
</FormLabel>
<FormControl>
<Switch
checked={field.value}
className="!mt-0 float-end"
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t(
"verify.enableRegisterVerifyDescription",
"When enabled, users must pass human verification during registration"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="enable_login_verify"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"verify.enableLoginVerify",
"Enable Verification on Login"
)}
</FormLabel>
<FormControl>
<Switch
checked={field.value}
className="!mt-0 float-end"
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t(
"verify.enableLoginVerifyDescription",
"When enabled, users must pass human verification during login"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="enable_password_verify"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"verify.enablePasswordVerify",
"Enable Verification on Password Reset"
)}
</FormLabel>
<FormControl>
<Switch
checked={field.value}
className="!mt-0 float-end"
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t(
"verify.enablePasswordVerifyDescription",
"When enabled, users must pass human verification during password reset"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<SheetFooter className="flex-row justify-end gap-2 pt-3">
<Button
disabled={loading}
onClick={() => setOpen(false)}
variant="outline"
>
{t("common.cancel", "Cancel")}
</Button>
<Button disabled={loading} form="verify-form" type="submit">
{loading && (
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
)}
{t("common.save", "Save Settings")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
+347
View File
@@ -0,0 +1,347 @@
import { useQuery } from "@tanstack/react-query";
import { Button } from "@workspace/ui/components/button";
import {
Drawer,
DrawerContent,
DrawerFooter,
DrawerHeader,
DrawerTitle,
} from "@workspace/ui/components/drawer";
import { Input } from "@workspace/ui/components/input";
import { Label } from "@workspace/ui/components/label";
import { ScrollArea } from "@workspace/ui/components/scroll-area";
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
import { Icon } from "@workspace/ui/composed/icon";
import {
ProTable,
type ProTableActions,
} from "@workspace/ui/composed/pro-table/pro-table";
import { cn } from "@workspace/ui/lib/utils";
import {
createTicketFollow,
getTicket,
getTicketList,
updateTicketStatus,
} from "@workspace/ui/services/admin/ticket";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { formatDate } from "@/utils/common";
import { UserDetail } from "../user/user-detail";
export default function Page() {
const { t } = useTranslation("ticket");
// i18n status declarations for extraction
// t("status.0", "Status")
// t("status.1", "Pending Follow-up")
// t("status.2", "Pending Reply")
// t("status.3", "Resolved")
// t("status.4", "Closed")
const [ticketId, setTicketId] = useState<any>(null);
const [message, setMessage] = useState("");
const { data: ticket, refetch: refetchTicket } = useQuery({
queryKey: ["getTicket", ticketId],
queryFn: async () => {
const { data } = await getTicket({
id: ticketId,
});
return data.data as API.Ticket;
},
enabled: !!ticketId,
refetchInterval: 5000,
});
const scrollRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
setTimeout(() => {
if (scrollRef.current) {
scrollRef.current.children[1]?.scrollTo({
top: scrollRef.current.children[1].scrollHeight,
behavior: "smooth",
});
}
}, 66);
}, [ticket?.follow?.length]);
const ref = useRef<ProTableActions>(null);
return (
<>
<ProTable<API.Ticket, { status: number }>
action={ref}
actions={{
render(row) {
if (row.status !== 4) {
return [
<Button key="reply" onClick={() => setTicketId(row.id)}>
{t("reply", "Reply")}
</Button>,
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description={t(
"closeWarning",
"Once closed, the ticket cannot be operated on. Please proceed with caution."
)}
key="colse"
onConfirm={async () => {
await updateTicketStatus({
id: row.id,
status: 4,
});
toast.success(t("closeSuccess", "Closed successfully"));
ref.current?.refresh();
}}
title={t("confirmClose", "Are you sure you want to close?")}
trigger={
<Button variant="destructive">{t("close", "Close")}</Button>
}
/>,
];
}
return [
<Button key="check" onClick={() => setTicketId(row.id)} size="sm">
{t("check", "Check")}
</Button>,
];
},
}}
columns={[
{
accessorKey: "title",
header: t("title", "Title"),
},
{
accessorKey: "user_id",
header: t("user", "User"),
cell: ({ row }) => <UserDetail id={row.original.user_id} />,
},
{
accessorKey: "status",
header: t("status.0", "Status"),
cell: ({ row }) => (
<span
className={cn(
"flex items-center gap-2 before:block before:size-1.5 before:animate-pulse before:rounded-full before:ring-2 before:ring-opacity-50",
{
"before:bg-rose-500 before:ring-rose-500":
row.original.status === 1,
"before:bg-yellow-500 before:ring-yellow-500":
row.original.status === 2,
"before:bg-green-500 before:ring-green-500":
row.original.status === 3,
"before:bg-zinc-500 before:ring-zinc-500":
row.original.status === 4,
}
)}
>
{t(`status.${row.original.status}`)}
</span>
),
},
{
accessorKey: "updated_at",
header: t("updatedAt", "Updated At"),
cell: ({ row }) => formatDate(row.getValue("updated_at")),
},
]}
header={{
title: t("ticketList", "Ticket List"),
}}
params={[
{
key: "status",
placeholder: t("status.0", "Status"),
options: [
{
label: t("close", "Close"),
value: "4",
},
],
},
]}
request={async (pagination, filters) => {
const { data } = await getTicketList({
...pagination,
...filters,
});
return {
list: data.data?.list || [],
total: data.data?.total || 0,
};
}}
/>
<Drawer
onOpenChange={(open) => {
if (!open) setTicketId(null);
}}
open={!!ticketId}
>
<DrawerContent className="container mx-auto h-screen *:select-text">
<DrawerHeader className="border-b text-left">
<DrawerTitle>{ticket?.title}</DrawerTitle>
</DrawerHeader>
<ScrollArea className="h-full overflow-hidden" ref={scrollRef}>
<div className="flex h-full flex-col gap-4 p-4">
{/* 显示工单描述作为第一条用户消息 */}
{ticket?.description && (
<div className="flex items-center gap-4">
<div className="flex flex-col gap-1">
<p className="text-muted-foreground text-sm">
{formatDate(ticket.created_at)}
</p>
<p className="w-fit rounded-lg bg-accent p-2 font-medium">
{ticket.description}
</p>
</div>
</div>
)}
{/* 显示后续跟进消息 */}
{ticket?.follow?.map((item) => (
<div
className={cn("flex items-center gap-4", {
"flex-row-reverse": item.from === "System",
})}
key={item.id}
>
<div
className={cn("flex flex-col gap-1", {
"items-end": item.from === "System",
})}
>
<p className="text-muted-foreground text-sm">
{formatDate(item.created_at)}
</p>
<p
className={cn(
"w-fit rounded-lg bg-accent p-2 font-medium",
{
"bg-primary text-primary-foreground":
item.from === "System",
}
)}
>
{item.type === 1 && item.content}
{item.type === 2 && (
// eslint-disable-next-line @next/next/no-img-element
<img
alt="attachment"
className="!size-auto object-cover"
height={300}
src={item.content!}
width={300}
/>
)}
</p>
</div>
</div>
))}
</div>
</ScrollArea>
{ticket?.status !== 4 && (
<DrawerFooter>
<form
className="flex w-full flex-row items-center gap-2"
onSubmit={async (event) => {
event.preventDefault();
if (message) {
await createTicketFollow({
ticket_id: ticketId,
from: "System",
type: 1,
content: message,
});
refetchTicket();
setMessage("");
}
}}
>
<Button className="p-0" type="button" variant="outline">
<Label className="p-2" htmlFor="picture">
<Icon className="text-2xl" icon="uil:image-upload" />
</Label>
<Input
accept="image/*"
className="hidden"
id="picture"
onChange={(event) => {
const file = event.target.files?.[0];
if (file?.type.startsWith("image/")) {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = (e) => {
const img = new Image();
img.src = e.target?.result as string;
img.onload = () => {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const maxWidth = 300;
const maxHeight = 300;
let width = img.width;
let height = img.height;
if (width > height) {
if (width > maxWidth) {
height = Math.round(
(maxWidth / width) * height
);
width = maxWidth;
}
} else if (height > maxHeight) {
width = Math.round((maxHeight / height) * width);
height = maxHeight;
}
canvas.width = width;
canvas.height = height;
ctx?.drawImage(img, 0, 0, width, height);
canvas.toBlob(
(blob) => {
const reader = new FileReader();
reader.readAsDataURL(blob!);
reader.onloadend = async () => {
await createTicketFollow({
ticket_id: ticketId,
from: "System",
type: 2,
content: reader.result as string,
});
refetchTicket();
};
},
"image/webp",
0.8
);
};
};
}
}}
type="file"
/>
</Button>
<Input
onChange={(e) => setMessage(e.target.value)}
placeholder={t(
"inputPlaceholder",
"Please enter your question, we will reply as soon as possible."
)}
value={message}
/>
<Button disabled={!message} type="submit">
<Icon icon="uil:navigator" />
</Button>
</form>
</DrawerFooter>
)}
</DrawerContent>
</Drawer>
</>
);
}
+373
View File
@@ -0,0 +1,373 @@
import { useQuery } from "@tanstack/react-query";
import { Link, useSearch } from "@tanstack/react-router";
import { Badge } from "@workspace/ui/components/badge";
import { Button } from "@workspace/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu";
import { ScrollArea } from "@workspace/ui/components/scroll-area";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@workspace/ui/components/sheet";
import { Switch } from "@workspace/ui/components/switch";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@workspace/ui/components/tabs";
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
import {
ProTable,
type ProTableActions,
} from "@workspace/ui/composed/pro-table/pro-table";
import {
createUser,
deleteUser,
getUserDetail,
getUserList,
updateUserBasicInfo,
} from "@workspace/ui/services/admin/user";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Display } from "@/components/display";
import { useSubscribe } from "@/stores/subscribe";
import { formatDate } from "@/utils/common";
import { UserDetail } from "./user-detail";
import UserForm from "./user-form";
import { AuthMethodsForm } from "./user-profile/auth-methods-form";
import { BasicInfoForm } from "./user-profile/basic-info-form";
import { NotifySettingsForm } from "./user-profile/notify-settings-form";
import UserSubscription from "./user-subscription";
export default function User() {
const { t } = useTranslation("user");
const [loading, setLoading] = useState(false);
const ref = useRef<ProTableActions>(null);
const sp = useSearch({ strict: false }) as Record<string, string | undefined>;
const { subscribes } = useSubscribe();
const initialFilters = {
search: sp.search || undefined,
user_id: sp.user_id || undefined,
subscribe_id: sp.subscribe_id || undefined,
user_subscribe_id: sp.user_subscribe_id || undefined,
};
return (
<ProTable<API.User, API.GetUserListParams>
action={ref}
actions={{
render: (row) => [
<ProfileSheet key="profile" userId={row.id} />,
<SubscriptionSheet key="subscription" userId={row.id} />,
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description={t(
"deleteDescription",
"This action cannot be undone."
)}
key="edit"
onConfirm={async () => {
await deleteUser({ id: row.id });
toast.success(t("deleteSuccess", "Deleted successfully"));
ref.current?.refresh();
}}
title={t("confirmDelete", "Confirm Delete")}
trigger={
<Button variant="destructive">{t("delete", "Delete")}</Button>
}
/>,
<DropdownMenu key="more">
<DropdownMenuTrigger asChild>
<Button variant="outline">{t("more", "More")}</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link
search={{ user_id: String(row.id) }}
to="/dashboard/order"
>
{t("orderList", "Order List")}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
search={{ user_id: String(row.id) }}
to="/dashboard/log/login"
>
{t("loginLogs", "Login Logs")}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
search={{ user_id: String(row.id) }}
to="/dashboard/log/balance"
>
{t("balanceLogs", "Balance Logs")}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
search={{ user_id: String(row.id) }}
to="/dashboard/log/commission"
>
{t("commissionLogs", "Commission Logs")}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
search={{ user_id: String(row.id) }}
to="/dashboard/log/gift"
>
{t("giftLogs", "Gift Logs")}
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>,
],
}}
columns={[
{
accessorKey: "enable",
header: t("enable", "Enable"),
cell: ({ row }) => (
<Switch
defaultChecked={row.getValue("enable")}
onCheckedChange={async (checked) => {
const {
auth_methods: _auth_methods,
user_devices: _user_devices,
enable_balance_notify: _enable_balance_notify,
enable_login_notify: _enable_login_notify,
enable_subscribe_notify: _enable_subscribe_notify,
enable_trade_notify: _enable_trade_notify,
updated_at: _updated_at,
created_at: _created_at,
id,
...rest
} = row.original;
await updateUserBasicInfo({
user_id: id,
...rest,
enable: checked,
} as unknown as API.UpdateUserBasiceInfoRequest);
toast.success(t("updateSuccess", "Updated successfully"));
ref.current?.refresh();
}}
/>
),
},
{
accessorKey: "id",
header: "ID",
},
{
accessorKey: "auth_methods",
header: t("userName", "Username"),
cell: ({ row }) => {
const method = row.original.auth_methods?.[0];
return (
<div>
<Badge
className="mr-1 uppercase"
title={method?.verified ? t("verified", "Verified") : ""}
>
{method?.auth_type}
</Badge>
{method?.auth_identifier}
</div>
);
},
},
{
accessorKey: "balance",
header: t("balance", "Balance"),
cell: ({ row }) => (
<Display type="currency" value={row.getValue("balance")} />
),
},
{
accessorKey: "gift_amount",
header: t("giftAmount", "Gift Amount"),
cell: ({ row }) => (
<Display type="currency" value={row.getValue("gift_amount")} />
),
},
{
accessorKey: "commission",
header: t("commission", "Commission"),
cell: ({ row }) => (
<Display type="currency" value={row.getValue("commission")} />
),
},
{
accessorKey: "refer_code",
header: t("inviteCode", "Invite Code"),
cell: ({ row }) => row.getValue("refer_code") || "--",
},
{
accessorKey: "referer_id",
header: t("referer", "Referer"),
cell: ({ row }) => <UserDetail id={row.original.referer_id} />,
},
{
accessorKey: "created_at",
header: t("createdAt", "Created At"),
cell: ({ row }) => formatDate(row.getValue("created_at")),
},
]}
header={{
title: t("userList", "User List"),
toolbar: (
<UserForm<API.CreateUserRequest>
key="create"
loading={loading}
onSubmit={async (values) => {
setLoading(true);
try {
await createUser(values);
toast.success(t("createSuccess", "Created successfully"));
ref.current?.refresh();
setLoading(false);
return true;
} catch {
setLoading(false);
return false;
}
}}
title={t("createUser", "Create User")}
trigger={t("create", "Create")}
/>
),
}}
initialFilters={initialFilters}
key={initialFilters.user_id}
params={[
{
key: "subscribe_id",
placeholder: t("subscription", "Subscription"),
options: subscribes?.map((item) => ({
label: item.name!,
value: String(item.id!),
})),
},
{
key: "search",
placeholder: "Search",
},
{
key: "user_id",
placeholder: t("userId", "User ID"),
},
{
key: "user_subscribe_id",
placeholder: t("subscriptionId", "Subscription ID"),
},
]}
request={async (pagination, filter) => {
const { data } = await getUserList({
...pagination,
...filter,
});
return {
list: data.data?.list || [],
total: data.data?.total || 0,
};
}}
/>
);
}
function ProfileSheet({ userId }: { userId: number }) {
const { t } = useTranslation("user");
const [open, setOpen] = useState(false);
const { data: user, refetch } = useQuery({
enabled: open,
queryKey: ["user", userId],
queryFn: async () => {
const { data } = await getUserDetail({ id: userId });
return data.data as API.User;
},
});
return (
<Sheet onOpenChange={setOpen} open={open}>
<SheetTrigger asChild>
<Button variant="default">{t("edit", "Edit")}</Button>
</SheetTrigger>
<SheetContent
className="w-[700px] max-w-full md:max-w-screen-lg"
side="right"
>
<SheetHeader>
<SheetTitle>
{t("userProfile", "User Profile")} · ID: {userId}
</SheetTitle>
</SheetHeader>
{user && (
<ScrollArea className="h-[calc(100dvh-140px)] p-2">
<Tabs defaultValue="basic">
<TabsList className="mb-3">
<TabsTrigger value="basic">
{t("basicInfoTitle", "Basic Info")}
</TabsTrigger>
<TabsTrigger value="notify">
{t("notifySettingsTitle", "Notify Settings")}
</TabsTrigger>
<TabsTrigger value="auth">
{t("authMethodsTitle", "Auth Methods")}
</TabsTrigger>
</TabsList>
<TabsContent className="mt-0" value="basic">
<BasicInfoForm refetch={refetch as any} user={user} />
</TabsContent>
<TabsContent className="mt-0" value="notify">
<NotifySettingsForm refetch={refetch as any} user={user} />
</TabsContent>
<TabsContent className="mt-0" value="auth">
<AuthMethodsForm refetch={refetch as any} user={user} />
</TabsContent>
</Tabs>
</ScrollArea>
)}
</SheetContent>
</Sheet>
);
}
function SubscriptionSheet({ userId }: { userId: number }) {
const { t } = useTranslation("user");
const [open, setOpen] = useState(false);
return (
<Sheet onOpenChange={setOpen} open={open}>
<SheetTrigger asChild>
<Button variant="secondary">{t("subscription", "Subscription")}</Button>
</SheetTrigger>
<SheetContent
className="w-[1000px] max-w-full md:max-w-screen-xl"
side="right"
>
<SheetHeader>
<SheetTitle>
{t("subscriptionList", "Subscription List")} · ID: {userId}
</SheetTitle>
</SheetHeader>
<div className="mt-2">
<UserSubscription userId={userId} />
</div>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,221 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { Button } from "@workspace/ui/components/button";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@workspace/ui/components/hover-card";
import {
getUserDetail,
getUserSubscribeById,
} from "@workspace/ui/services/admin/user";
import { formatBytes } from "@workspace/ui/utils/formatting";
import { useTranslation } from "react-i18next";
import { Display } from "@/components/display";
import { formatDate } from "@/utils/common";
export function UserSubscribeDetail({
id,
enabled,
hoverCard = false,
}: {
id: number;
enabled: boolean;
hoverCard?: boolean;
}) {
const { t } = useTranslation("user");
const { data } = useQuery({
enabled: id !== 0 && enabled,
queryKey: ["getUserSubscribeById", id],
queryFn: async () => {
const { data } = await getUserSubscribeById({ id });
return data.data;
},
});
if (!id) return "--";
const usedTraffic = data ? data.upload + data.download : 0;
const totalTraffic = data?.traffic || 0;
const subscribeContent = (
<div className="space-y-4">
<div>
<h3 className="mb-2 font-medium text-sm">{t("subscriptionInfo")}</h3>
<div className="rounded-lg bg-muted/30 p-3">
<ul className="grid gap-3">
<li className="flex items-center justify-between font-semibold">
<span className="text-muted-foreground">
{t("subscriptionId")}
</span>
<span>{data?.id || "--"}</span>
</li>
<li className="flex items-center justify-between">
<span className="text-muted-foreground">
{t("subscriptionName")}
</span>
<span>{data?.subscribe?.name || "--"}</span>
</li>
<li className="flex items-center justify-between">
<span className="text-muted-foreground">{t("token")}</span>
<div className="font-mono text-xs" title={data?.token || ""}>
{data?.token || "--"}
</div>
</li>
<li className="flex items-center justify-between">
<span className="text-muted-foreground">{t("trafficUsage")}</span>
<span>
{data
? totalTraffic === 0
? `${formatBytes(usedTraffic)} / ${t("unlimited")}`
: `${formatBytes(usedTraffic)} / ${formatBytes(totalTraffic)}`
: "--"}
</span>
</li>
<li className="flex items-center justify-between">
<span className="text-muted-foreground">{t("startTime")}</span>
<span>
{data?.start_time ? formatDate(data.start_time) : "--"}
</span>
</li>
<li className="flex items-center justify-between">
<span className="text-muted-foreground">{t("expireTime")}</span>
<span>
{data?.expire_time ? formatDate(data.expire_time) : "--"}
</span>
</li>
</ul>
</div>
</div>
{!hoverCard && (
<div>
<h3 className="mb-2 font-medium text-sm">
{t("userInfo")}
{/* Removed link to legacy user detail page */}
</h3>
<ul className="grid gap-3">
<li className="flex items-center justify-between font-semibold">
<span className="text-muted-foreground">{t("userId")}</span>
<span>{data?.user_id}</span>
</li>
<li className="flex items-center justify-between font-semibold">
<span className="text-muted-foreground">{t("balance")}</span>
<span>
<Display type="currency" value={data?.user.balance} />
</span>
</li>
<li className="flex items-center justify-between">
<span className="text-muted-foreground">{t("giftAmount")}</span>
<span>
<Display type="currency" value={data?.user?.gift_amount} />
</span>
</li>
<li className="flex items-center justify-between">
<span className="text-muted-foreground">{t("commission")}</span>
<span>
<Display type="currency" value={data?.user?.commission} />
</span>
</li>
<li className="flex items-center justify-between">
<span className="text-muted-foreground">{t("createdAt")}</span>
<span>
{data?.user?.created_at && formatDate(data?.user?.created_at)}
</span>
</li>
</ul>
</div>
)}
</div>
);
if (hoverCard) {
return (
<HoverCard>
<HoverCardTrigger asChild>
<Button className="p-0" variant="link">
{data?.subscribe?.name || t("loading")}
</Button>
</HoverCardTrigger>
<HoverCardContent className="w-96">{subscribeContent}</HoverCardContent>
</HoverCard>
);
}
return subscribeContent;
}
export function UserDetail({ id }: { id: number }) {
const { t } = useTranslation("user");
const { data } = useQuery({
enabled: id !== 0,
queryKey: ["getUserDetail", id],
queryFn: async () => {
const { data } = await getUserDetail({ id });
return data.data;
},
});
if (!id) return "--";
const identifier =
data?.auth_methods.find((m) => m.auth_type === "email")?.auth_identifier ||
data?.auth_methods[0]?.auth_identifier;
return (
<HoverCard>
<HoverCardTrigger asChild>
<Button asChild className="p-0" variant="link">
<Link search={{ user_id: id }} to="/dashboard/user">
{identifier || t("loading", "Loading...")}
</Link>
</Button>
</HoverCardTrigger>
<HoverCardContent>
<div className="grid gap-3">
<ul className="grid gap-3">
<li className="flex items-center justify-between font-semibold">
<span className="text-muted-foreground">ID</span>
<span>{data?.id}</span>
</li>
<li className="flex items-center justify-between font-semibold">
<span className="text-muted-foreground">
{t("balance", "Balance")}
</span>
<span>
<Display type="currency" value={data?.balance} />
</span>
</li>
<li className="flex items-center justify-between">
<span className="text-muted-foreground">
{t("giftAmount", "Gift Amount")}
</span>
<span>
<Display type="currency" value={data?.gift_amount} />
</span>
</li>
<li className="flex items-center justify-between">
<span className="text-muted-foreground">
{t("commission", "Commission")}
</span>
<span>
<Display type="currency" value={data?.commission} />
</span>
</li>
<li className="flex items-center justify-between">
<span className="text-muted-foreground">
{t("createdAt", "Created At")}
</span>
<span>{data?.created_at && formatDate(data?.created_at)}</span>
</li>
</ul>
</div>
</HoverCardContent>
</HoverCard>
);
}
+416
View File
@@ -0,0 +1,416 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@workspace/ui/components/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@workspace/ui/components/form";
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 { AreaCodeSelect } from "@workspace/ui/composed/area-code-select";
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
import { Icon } from "@workspace/ui/composed/icon";
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 { z } from "zod";
import { useGlobalStore } from "@/stores/global";
interface UserFormProps<T> {
onSubmit: (data: T) => Promise<boolean> | boolean;
initialValues?: T;
loading?: boolean;
trigger: string;
title: string;
update?: boolean;
}
export default function UserForm<T extends Record<string, any>>({
onSubmit,
initialValues,
loading,
trigger,
title,
}: Readonly<UserFormProps<T>>) {
const { t } = useTranslation("user");
const { common } = useGlobalStore();
const { currency } = common;
const [open, setOpen] = useState(false);
const formSchema = z.object({
email: z.email(t("invalidEmailFormat", "Invalid email format")),
telephone_area_code: z.string().optional(),
telephone: z.string().optional(),
password: z.string().optional(),
referer_id: z.number().optional(),
refer_code: z.string().optional(),
referral_percentage: z.number().optional(),
only_first_purchase: z.boolean().optional(),
is_admin: z.boolean().optional(),
balance: z.number().optional(),
gift_amount: z.number().optional(),
commission: z.number().optional(),
});
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: {
...initialValues,
},
});
useEffect(() => {
form?.reset(initialValues);
}, [form, initialValues]);
async function handleSubmit(data: { [x: string]: any }) {
const bool = await onSubmit(data as T);
if (bool) setOpen(false);
}
return (
<Sheet onOpenChange={setOpen} open={open}>
<SheetTrigger asChild>
<Button
onClick={() => {
form.reset();
setOpen(true);
}}
>
{trigger}
</Button>
</SheetTrigger>
<SheetContent className="w-[500px] max-w-full gap-0 md:max-w-screen-md">
<SheetHeader>
<SheetTitle>{title}</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))]">
<Form {...form}>
<form
className="space-y-4 px-6 pt-4"
onSubmit={form.handleSubmit(handleSubmit)}
>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>{t("userEmail", "Email")}</FormLabel>
<FormControl>
<EnhancedInput
placeholder={t("userEmailPlaceholder", "Enter email")}
{...field}
onValueChange={(value) => {
form.setValue(field.name, value);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="telephone"
render={({ field }) => (
<FormItem>
<FormLabel>{t("telephone", "Phone")}</FormLabel>
<FormControl>
<EnhancedInput
placeholder={t(
"telephonePlaceholder",
"Enter phone number"
)}
prefix={
<FormField
control={form.control}
name="telephone_area_code"
render={({ field }) => (
<FormItem>
<FormControl>
<AreaCodeSelect
className="w-32 rounded-none border-y-0 border-l-0"
onChange={(value) => {
form.setValue(
field.name,
value.phone as string
);
}}
placeholder={t(
"areaCodePlaceholder",
"Area code"
)}
simple
value={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
}
{...field}
onValueChange={(value) => {
form.setValue(field.name, value);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>{t("password", "Password")}</FormLabel>
<FormControl>
<EnhancedInput
autoComplete="new-password"
placeholder={t("passwordPlaceholder", "Enter password")}
{...field}
onValueChange={(value) => {
form.setValue(field.name, value);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="referer_id"
render={({ field }) => (
<FormItem>
<FormLabel>{t("refererId", "Referer ID")}</FormLabel>
<FormControl>
<EnhancedInput
placeholder={t(
"refererIdPlaceholder",
"Enter referer ID"
)}
{...field}
onValueChange={(value) => {
form.setValue(field.name, value);
}}
type="number"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="refer_code"
render={({ field }) => (
<FormItem>
<FormLabel>{t("inviteCode", "Invite Code")}</FormLabel>
<FormControl>
<EnhancedInput
placeholder={t(
"inviteCodePlaceholder",
"Enter invite code"
)}
{...field}
onValueChange={(value) => {
form.setValue(field.name, value);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="referral_percentage"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("referralPercentage", "Referral Percentage")}
</FormLabel>
<FormControl>
<EnhancedInput
max={100}
min={0}
placeholder={t(
"referralPercentagePlaceholder",
"Enter percentage"
)}
type="number"
{...field}
onValueChange={(value) => {
form.setValue(field.name, Number(value));
}}
suffix="%"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="only_first_purchase"
render={({ field }) => (
<FormItem className="flex items-center justify-between space-x-2">
<FormLabel>
{t("onlyFirstPurchase", "First Purchase Only")}
</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="balance"
render={({ field }) => (
<FormItem>
<FormLabel>{t("balance", "Balance")}</FormLabel>
<FormControl>
<EnhancedInput
placeholder={t("balancePlaceholder", "Enter balance")}
prefix={currency?.currency_symbol ?? "$"}
type="number"
{...field}
formatInput={(value) =>
unitConversion("centsToDollars", value)
}
formatOutput={(value) =>
unitConversion("dollarsToCents", value)
}
min={0}
onValueChange={(value) => {
form.setValue(field.name, value);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="gift_amount"
render={({ field }) => (
<FormItem>
<FormLabel>{t("giftAmount", "Gift Amount")}</FormLabel>
<FormControl>
<EnhancedInput
placeholder={t(
"giftAmountPlaceholder",
"Enter gift amount"
)}
prefix={currency?.currency_symbol ?? "$"}
type="number"
{...field}
formatInput={(value) =>
unitConversion("centsToDollars", value)
}
formatOutput={(value) =>
unitConversion("dollarsToCents", value)
}
min={0}
onValueChange={(value) => {
form.setValue(field.name, value);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="commission"
render={({ field }) => (
<FormItem>
<FormLabel>{t("commission", "Commission")}</FormLabel>
<FormControl>
<EnhancedInput
placeholder={t(
"commissionPlaceholder",
"Enter commission"
)}
prefix={currency?.currency_symbol ?? "$"}
type="number"
{...field}
formatInput={(value) =>
unitConversion("centsToDollars", value)
}
formatOutput={(value) =>
unitConversion("dollarsToCents", value)
}
min={0}
onValueChange={(value) => {
form.setValue(field.name, value);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="is_admin"
render={({ field }) => (
<FormItem>
<FormLabel>{t("manager", "Administrator")}</FormLabel>
<FormControl>
<div className="pt-2">
<Switch
checked={!!field.value}
onCheckedChange={field.onChange}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<SheetFooter className="flex-row justify-end gap-2 pt-3">
<Button
disabled={loading}
onClick={() => {
setOpen(false);
}}
variant="outline"
>
{t("cancel", "Cancel")}
</Button>
<Button disabled={loading} onClick={form.handleSubmit(handleSubmit)}>
{loading && (
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
)}{" "}
{t("confirm", "Confirm")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,168 @@
import { Badge } from "@workspace/ui/components/badge";
import { Button } from "@workspace/ui/components/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
import {
createUserAuthMethod,
deleteUserAuthMethod,
updateUserAuthMethod,
} from "@workspace/ui/services/admin/user";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
export function AuthMethodsForm({
user,
refetch,
}: {
user: API.User;
refetch: () => void;
}) {
const { t } = useTranslation("user");
const [emailChanges, setEmailChanges] = useState<Record<string, string>>({});
const handleRemoveAuth = async (authType: string) => {
await deleteUserAuthMethod({
user_id: user.id,
auth_type: authType,
});
toast.success(t("deleteSuccess", "Deleted successfully"));
};
const handleUpdateEmail = async (email: string) => {
await updateUserAuthMethod({
user_id: user.id,
auth_type: "email",
auth_identifier: email,
});
toast.success(t("updateSuccess", "Updated successfully"));
refetch();
};
const handleCreateEmail = async (email: string) => {
await createUserAuthMethod({
user_id: user.id,
auth_type: "email",
auth_identifier: email,
});
toast.success(t("createSuccess", "Created successfully"));
refetch();
};
const handleEmailChange = (authType: string, value: string) => {
setEmailChanges((prev) => ({
...prev,
[authType]: value,
}));
};
const emailMethod = user.auth_methods.find(
(method) => method.auth_type === "email"
);
const otherMethods = user.auth_methods.filter(
(method) => method.auth_type !== "email"
);
const defaultEmailMethod = {
auth_type: "email",
auth_identifier: "",
verified: false,
...emailMethod,
};
const isEmailExists = !!emailMethod;
const handleEmailAction = () => {
const email = emailChanges.email;
if (isEmailExists) {
handleUpdateEmail(email as string);
} else {
handleCreateEmail(email as string);
}
};
return (
<Card>
<CardHeader>
<CardTitle>{t("authMethodsTitle", "Auth Methods")}</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-6">
<Card className="border-none shadow-none">
<CardContent className="space-y-3 p-0">
<div className="flex items-center justify-between">
<div className="font-medium uppercase">email</div>
<Badge
variant={
defaultEmailMethod.verified ? "default" : "destructive"
}
>
{defaultEmailMethod.verified
? t("verified", "Verified")
: t("unverified", "Unverified")}
</Badge>
</div>
<div className="flex items-center gap-2">
<div className="flex-1">
<EnhancedInput
onValueChange={(value) =>
handleEmailChange("email", value as string)
}
placeholder={t("pleaseEnterEmail", "Enter email")}
value={defaultEmailMethod.auth_identifier}
/>
</div>
<Button
disabled={
!emailChanges.email ||
(isEmailExists &&
emailChanges.email === defaultEmailMethod.auth_identifier)
}
onClick={handleEmailAction}
>
{isEmailExists ? t("update", "Update") : t("add", "Add")}
</Button>
</div>
</CardContent>
</Card>
{otherMethods.map((method) => (
<Card className="border-none shadow-none" key={method.auth_type}>
<CardContent className="space-y-3 p-0">
<div className="flex items-center justify-between">
<div className="font-medium uppercase">
{method.auth_type}
</div>
<Badge variant={method.verified ? "default" : "destructive"}>
{method.verified
? t("verified", "Verified")
: t("unverified", "Unverified")}
</Badge>
</div>
<div className="flex items-center gap-4">
<div className="flex-1">
<div className="text-muted-foreground text-sm">
{method.auth_identifier}
</div>
</div>
<Button
onClick={() => handleRemoveAuth(method.auth_type)}
size="sm"
variant="destructive"
>
{t("remove", "Remove")}
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,346 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@workspace/ui/components/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@workspace/ui/components/form";
import { Input } from "@workspace/ui/components/input";
import { Switch } from "@workspace/ui/components/switch";
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
import { UploadImage } from "@workspace/ui/composed/upload-image";
import { updateUserBasicInfo } from "@workspace/ui/services/admin/user";
import { unitConversion } from "@workspace/ui/utils/unit-conversions";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import * as z from "zod";
import { useGlobalStore } from "@/stores/global";
const basicInfoSchema = z.object({
avatar: z.string().optional(),
balance: z.number().optional(),
commission: z.number().optional(),
gift_amount: z.number().optional(),
refer_code: z.string().optional(),
referer_id: z.number().optional(),
referral_percentage: z.number().optional(),
only_first_purchase: z.boolean().optional(),
is_admin: z.boolean().optional(),
password: z.string().optional(),
enable: z.boolean(),
});
type BasicInfoValues = z.infer<typeof basicInfoSchema>;
export function BasicInfoForm({
user,
refetch,
}: {
user: API.User;
refetch: () => void;
}) {
const { t } = useTranslation("user");
const { common } = useGlobalStore();
const { currency } = common;
const form = useForm<BasicInfoValues>({
resolver: zodResolver(basicInfoSchema),
defaultValues: {
avatar: user.avatar,
balance: user.balance,
commission: user.commission,
gift_amount: user.gift_amount,
refer_code: user.refer_code,
referer_id: user.referer_id,
referral_percentage: user.referral_percentage,
only_first_purchase: user.only_first_purchase,
is_admin: user.is_admin,
enable: user.enable,
},
});
async function onSubmit(data: BasicInfoValues) {
await updateUserBasicInfo({
user_id: user.id,
telegram: user.telegram,
...data,
} as API.UpdateUserBasiceInfoRequest);
toast.success(t("updateSuccess", "Updated successfully"));
refetch();
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>{t("basicInfoTitle", "Basic Info")}</CardTitle>
<Button size="sm" type="submit">
{t("save", "Save")}
</Button>
</CardHeader>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="enable"
render={({ field }) => (
<FormItem className="flex items-center justify-between space-x-2">
<FormLabel>{t("accountEnable", "Account Enable")}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="is_admin"
render={({ field }) => (
<FormItem className="flex items-center justify-between space-x-2">
<FormLabel>{t("administrator", "Administrator")}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<div className="grid grid-cols-3 gap-4">
<FormField
control={form.control}
name="balance"
render={({ field }) => (
<FormItem>
<FormLabel>{t("balance", "Balance")}</FormLabel>
<FormControl>
<EnhancedInput
formatInput={(value) =>
unitConversion("centsToDollars", value)
}
formatOutput={(value) =>
unitConversion("dollarsToCents", value)
}
min={0}
onValueChange={(value) => {
form.setValue(field.name, value as number);
}}
prefix={currency?.currency_symbol ?? "$"}
type="number"
value={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="commission"
render={({ field }) => (
<FormItem>
<FormLabel>{t("commission", "Commission")}</FormLabel>
<FormControl>
<EnhancedInput
formatInput={(value) =>
unitConversion("centsToDollars", value)
}
formatOutput={(value) =>
unitConversion("dollarsToCents", value)
}
min={0}
onValueChange={(value) => {
form.setValue(field.name, value as number);
}}
prefix={currency?.currency_symbol ?? "$"}
type="number"
value={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="gift_amount"
render={({ field }) => (
<FormItem>
<FormLabel>{t("giftAmount", "Gift Amount")}</FormLabel>
<FormControl>
<EnhancedInput
formatInput={(value) =>
unitConversion("centsToDollars", value)
}
formatOutput={(value) =>
unitConversion("dollarsToCents", value)
}
min={0}
onValueChange={(value) => {
form.setValue(field.name, value as number);
}}
prefix={currency?.currency_symbol ?? "$"}
type="number"
value={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="refer_code"
render={({ field }) => (
<FormItem>
<FormLabel>{t("referralCode", "Referral Code")}</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={(value) => {
form.setValue(field.name, value as string);
}}
value={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="referer_id"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("referrerUserId", "Referrer User ID")}
</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={(value) => {
form.setValue(field.name, value as number);
}}
type="number"
value={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="referral_percentage"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("referralPercentage", "Referral Percentage")}
</FormLabel>
<FormControl>
<EnhancedInput
max={100}
min={0}
onValueChange={(value) => {
form.setValue(field.name, value as number);
}}
suffix="%"
type="number"
value={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="only_first_purchase"
render={({ field }) => (
<FormItem className="flex items-center justify-between space-x-2">
<FormLabel>
{t("onlyFirstPurchase", "First Purchase Only")}
</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="avatar"
render={({ field }) => (
<FormItem>
<FormLabel>{t("avatar", "Avatar")}</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={(value) => {
form.setValue(field.name, value as string);
}}
suffix={
<UploadImage
className="h-9 rounded-none border-none bg-muted px-2"
onChange={(value) =>
form.setValue("avatar", value as string)
}
returnType="base64"
/>
}
value={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>{t("password", "Password")}</FormLabel>
<FormControl>
<Input
placeholder={t(
"passwordPlaceholder",
"Enter new password"
)}
type="password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
</form>
</Form>
);
}
@@ -0,0 +1,152 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@workspace/ui/components/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
} from "@workspace/ui/components/form";
import { Switch } from "@workspace/ui/components/switch";
import { updateUserNotifySetting } from "@workspace/ui/services/admin/user";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import * as z from "zod";
const notifySettingsSchema = z.object({
enable_balance_notify: z.boolean(),
enable_login_notify: z.boolean(),
enable_subscribe_notify: z.boolean(),
enable_trade_notify: z.boolean(),
});
type NotifySettingsValues = z.infer<typeof notifySettingsSchema>;
export function NotifySettingsForm({
user,
refetch,
}: {
user: API.User;
refetch: () => void;
}) {
const { t } = useTranslation("user");
const form = useForm<NotifySettingsValues>({
resolver: zodResolver(notifySettingsSchema),
defaultValues: {
enable_balance_notify: user.enable_balance_notify,
enable_login_notify: user.enable_login_notify,
enable_subscribe_notify: user.enable_subscribe_notify,
enable_trade_notify: user.enable_trade_notify,
},
});
async function onSubmit(data: NotifySettingsValues) {
await updateUserNotifySetting({
...data,
user_id: user.id,
});
toast.success(t("updateSuccess", "Updated successfully"));
refetch();
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>{t("notifySettingsTitle", "Notify Settings")}</CardTitle>
<Button size="sm" type="submit">
{t("save", "Save")}
</Button>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 gap-4">
<FormField
control={form.control}
name="enable_balance_notify"
render={({ field }) => (
<FormItem className="flex items-center justify-between space-x-2">
<FormLabel>
{t("balanceNotifications", "Balance Notifications")}
</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="enable_login_notify"
render={({ field }) => (
<FormItem className="flex items-center justify-between space-x-2">
<FormLabel>
{t("loginNotifications", "Login Notifications")}
</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="enable_subscribe_notify"
render={({ field }) => (
<FormItem className="flex items-center justify-between space-x-2">
<FormLabel>
{t(
"subscriptionNotifications",
"Subscription Notifications"
)}
</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="enable_trade_notify"
render={({ field }) => (
<FormItem className="flex items-center justify-between space-x-2">
<FormLabel>
{t("tradeNotifications", "Trade Notifications")}
</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
</CardContent>
</Card>
</form>
</Form>
);
}
@@ -0,0 +1,255 @@
import { Button } from "@workspace/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu";
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
import {
ProTable,
type ProTableActions,
} from "@workspace/ui/composed/pro-table/pro-table";
import {
createUserSubscribe,
deleteUserSubscribe,
getUserSubscribe,
updateUserSubscribe,
} from "@workspace/ui/services/admin/user";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Display } from "@/components/display";
import { useGlobalStore } from "@/stores/global";
import { formatDate } from "@/utils/common";
import { SubscriptionDetail } from "./subscription-detail";
import { SubscriptionForm } from "./subscription-form";
export default function UserSubscription({ userId }: { userId: number }) {
const { t } = useTranslation("user");
const [loading, setLoading] = useState(false);
const ref = useRef<ProTableActions>(null);
const { getUserSubscribe: getUserSubscribeUrls } = useGlobalStore();
return (
<ProTable<API.UserSubscribe, Record<string, unknown>>
action={ref}
actions={{
render: (row) => [
<SubscriptionForm
initialData={row}
key="edit"
loading={loading}
onSubmit={async (values) => {
setLoading(true);
await updateUserSubscribe({
user_id: Number(userId),
user_subscribe_id: row.id,
...values,
});
toast.success(t("updateSuccess", "Updated successfully"));
ref.current?.refresh();
setLoading(false);
return true;
}}
title={t("editSubscription", "Edit Subscription")}
trigger={t("edit", "Edit")}
/>,
<Button
key="copy"
onClick={async () => {
await navigator.clipboard.writeText(
getUserSubscribeUrls(row.token)[0] || ""
);
toast.success(t("copySuccess", "Copied successfully"));
}}
variant="secondary"
>
{t("copySubscription", "Copy Subscription")}
</Button>,
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description={t(
"deleteSubscriptionDescription",
"This action cannot be undone."
)}
key="delete"
onConfirm={async () => {
await deleteUserSubscribe({ user_subscribe_id: row.id });
toast.success(t("deleteSuccess", "Deleted successfully"));
ref.current?.refresh();
}}
title={t("confirmDelete", "Confirm Delete")}
trigger={
<Button variant="destructive">{t("delete", "Delete")}</Button>
}
/>,
<RowMoreActions key="more" subId={row.id} userId={userId} />,
],
}}
columns={[
{
accessorKey: "id",
header: "ID",
},
{
accessorKey: "name",
header: t("subscriptionName", "Subscription Name"),
cell: ({ row }) => row.original.subscribe.name,
},
{
accessorKey: "upload",
header: t("upload", "Upload"),
cell: ({ row }) => (
<Display type="traffic" value={row.getValue("upload")} />
),
},
{
accessorKey: "download",
header: t("download", "Download"),
cell: ({ row }) => (
<Display type="traffic" value={row.getValue("download")} />
),
},
{
accessorKey: "traffic",
header: t("totalTraffic", "Total Traffic"),
cell: ({ row }) => (
<Display type="traffic" unlimited value={row.getValue("traffic")} />
),
},
{
accessorKey: "speed_limit",
header: t("speedLimit", "Speed Limit"),
cell: ({ row }) => {
const speed = row.original?.subscribe?.speed_limit;
return <Display type="trafficSpeed" value={speed} />;
},
},
{
accessorKey: "device_limit",
header: t("deviceLimit", "Device Limit"),
cell: ({ row }) => {
const limit = row.original?.subscribe?.device_limit;
return <Display type="number" unlimited value={limit} />;
},
},
{
accessorKey: "reset_time",
header: t("resetTime", "Reset Time"),
cell: ({ row }) => (
<Display
type="number"
unlimited
value={row.getValue("reset_time")}
/>
),
},
{
accessorKey: "expire_time",
header: t("expireTime", "Expire Time"),
cell: ({ row }) =>
row.getValue("expire_time")
? formatDate(row.getValue("expire_time"))
: t("permanent", "Permanent"),
},
{
accessorKey: "created_at",
header: t("createdAt", "Created At"),
cell: ({ row }) => formatDate(row.getValue("created_at")),
},
]}
header={{
title: t("subscriptionList", "Subscription List"),
toolbar: (
<SubscriptionForm
key="create"
loading={loading}
onSubmit={async (values) => {
setLoading(true);
await createUserSubscribe({
user_id: Number(userId),
...values,
});
toast.success(t("createSuccess", "Created successfully"));
ref.current?.refresh();
setLoading(false);
return true;
}}
title={t("createSubscription", "Create Subscription")}
trigger={t("add", "Add")}
/>
),
}}
request={async (pagination) => {
const { data } = await getUserSubscribe({
user_id: userId,
...pagination,
});
return {
list: data.data?.list || [],
total: data.data?.total || 0,
};
}}
/>
);
}
function RowMoreActions({ userId, subId }: { userId: number; subId: number }) {
const triggerRef = useRef<HTMLButtonElement>(null);
const { t } = useTranslation("user");
return (
<div className="inline-flex">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">{t("more", "More")}</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<a
href={`/dashboard/log/subscribe?user_id=${userId}&user_subscribe_id=${subId}`}
>
{t("subscriptionLogs", "Subscription Logs")}
</a>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<a
href={`/dashboard/log/reset-subscribe?user_id=${userId}&user_subscribe_id=${subId}`}
>
{t("resetLogs", "Reset Logs")}
</a>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<a
href={`/dashboard/log/subscribe-traffic?user_id=${userId}&user_subscribe_id=${subId}`}
>
{t("trafficStats", "Traffic Stats")}
</a>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<a
href={`/dashboard/log/traffic-details?user_id=${userId}&subscribe_id=${subId}`}
>
{t("trafficDetails", "Traffic Details")}
</a>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault();
triggerRef.current?.click();
}}
>
{t("onlineDevices", "Online Devices")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<SubscriptionDetail
subscriptionId={subId}
trigger={<Button className="hidden" ref={triggerRef} />}
userId={userId}
/>
</div>
);
}
@@ -0,0 +1,134 @@
import { Badge } from "@workspace/ui/components/badge";
import { Button } from "@workspace/ui/components/button";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@workspace/ui/components/sheet";
import { Switch } from "@workspace/ui/components/switch";
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
import { ProTable } from "@workspace/ui/composed/pro-table/pro-table";
import {
getUserSubscribeDevices,
kickOfflineByUserDevice,
} from "@workspace/ui/services/admin/user";
import { type ReactNode, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { IpLink } from "@/components/ip-link";
import { formatDate } from "@/utils/common";
export function SubscriptionDetail({
trigger,
userId,
subscriptionId,
}: {
trigger: ReactNode;
userId: number;
subscriptionId: number;
}) {
const { t } = useTranslation("user");
const [open, setOpen] = useState(false);
return (
<Sheet onOpenChange={setOpen} open={open}>
<SheetTrigger asChild>{trigger}</SheetTrigger>
<SheetContent
className="w-[700px] max-w-full md:max-w-screen-md"
side="right"
>
<SheetHeader>
<SheetTitle>{t("onlineDevices", "Online Devices")}</SheetTitle>
</SheetHeader>
<div className="mt-4 max-h-[calc(100dvh-120px)] overflow-y-auto">
<ProTable<API.UserDevice, Record<string, unknown>>
actions={{
render: (row) => {
if (!row.identifier) return [];
return [
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description={t(
"kickOfflineConfirm",
`Kick device ${row.ip} offline?`
)}
key="offline"
onConfirm={async () => {
await kickOfflineByUserDevice({ id: row.id });
toast.success(
t("kickOfflineSuccess", "Device kicked offline")
);
}}
title={t("confirmOffline", "Confirm Offline")}
trigger={
<Button variant="destructive">
{t("confirmOffline", "Confirm Offline")}
</Button>
}
/>,
];
},
}}
columns={[
{
accessorKey: "enabled",
header: t("enable", "Enable"),
cell: ({ row }) => (
<Switch
checked={row.getValue("enabled")}
onChange={(checked) => {
console.log("Switch:", checked);
}}
/>
),
},
{ accessorKey: "id", header: "ID" },
{ accessorKey: "identifier", header: "IMEI" },
{
accessorKey: "user_agent",
header: t("userAgent", "User Agent"),
},
{
accessorKey: "ip",
header: "IP",
cell: ({ row }) => <IpLink ip={row.getValue("ip")} />,
},
{
accessorKey: "online",
header: t("loginStatus", "Login Status"),
cell: ({ row }) => (
<Badge
variant={row.getValue("online") ? "default" : "destructive"}
>
{row.getValue("online")
? t("online", "Online")
: t("offline", "Offline")}
</Badge>
),
},
{
accessorKey: "updated_at",
header: t("lastSeen", "Last Seen"),
cell: ({ row }) => formatDate(row.getValue("updated_at")),
},
]}
request={async (pagination) => {
const { data } = await getUserSubscribeDevices({
user_id: userId,
subscribe_id: subscriptionId,
...pagination,
});
return {
list: data.data?.list || [],
total: data.data?.total || 0,
};
}}
/>
</div>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,258 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@workspace/ui/components/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@workspace/ui/components/form";
import { ScrollArea } from "@workspace/ui/components/scroll-area";
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@workspace/ui/components/sheet";
import { Combobox } from "@workspace/ui/composed/combobox";
import { DatePicker } from "@workspace/ui/composed/date-picker";
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
import { Icon } from "@workspace/ui/composed/icon";
import { unitConversion } from "@workspace/ui/utils/unit-conversions";
import { type ReactNode, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { useSubscribe } from "@/stores/subscribe";
interface Props {
trigger: ReactNode;
title: string;
loading?: boolean;
initialData?: API.UserSubscribe;
onSubmit: (values: any) => Promise<boolean>;
}
const formSchema = z.object({
subscribe_id: z.number().optional(),
traffic: z.number().optional(),
speed_limit: z.number().optional(),
device_limit: z.number().optional(),
expired_at: z.number().nullish().optional(),
upload: z.number().optional(),
download: z.number().optional(),
id: z.number().optional(),
});
export function SubscriptionForm({
trigger,
title,
loading,
initialData,
onSubmit,
}: Props) {
const { t } = useTranslation("user");
const [open, setOpen] = useState(false);
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: {
subscribe_id: initialData?.subscribe_id || 0,
traffic: initialData?.traffic || 0,
upload: initialData?.upload || 0,
download: initialData?.download || 0,
expired_at: initialData?.expire_time || 0,
...(initialData && { id: initialData.id }),
},
});
const handleSubmit = async (values: any) => {
const success = await onSubmit(values);
if (success) {
setOpen(false);
form.reset();
}
};
const { subscribes } = useSubscribe();
return (
<Sheet onOpenChange={setOpen} open={open}>
<SheetTrigger asChild>
<Button
onClick={() => {
form.reset();
setOpen(true);
}}
>
{trigger}
</Button>
</SheetTrigger>
<SheetContent side="right">
<SheetHeader>
<SheetTitle>{title}</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))]">
<Form {...form}>
<form
className="mt-4 space-y-4"
onSubmit={form.handleSubmit(handleSubmit)}
>
<FormField
control={form.control}
name="subscribe_id"
render={({ field }) => (
<FormItem>
<FormLabel>{t("subscription", "Subscription")}</FormLabel>
<FormControl>
<Combobox<number, false>
onChange={(value) => {
form.setValue(field.name, value);
}}
options={subscribes?.map((item) => ({
value: item.id!,
label: item.name!,
}))}
placeholder="Select Subscription"
value={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="traffic"
render={({ field }) => (
<FormItem>
<FormLabel>{t("trafficLimit", "Traffic Limit")}</FormLabel>
<FormControl>
<EnhancedInput
placeholder={t("unlimited", "Unlimited")}
type="number"
{...field}
formatInput={(value) =>
unitConversion("bytesToGb", value)
}
formatOutput={(value) =>
unitConversion("gbToBytes", value)
}
onValueChange={(value) => {
form.setValue(field.name, value as number);
}}
suffix="GB"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="upload"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("uploadTraffic", "Upload Traffic")}
</FormLabel>
<FormControl>
<EnhancedInput
placeholder="0"
type="number"
{...field}
formatInput={(value) =>
unitConversion("bytesToGb", value)
}
formatOutput={(value) =>
unitConversion("gbToBytes", value)
}
onValueChange={(value) => {
form.setValue(field.name, value as number);
}}
suffix="GB"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="download"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("downloadTraffic", "Download Traffic")}
</FormLabel>
<FormControl>
<EnhancedInput
placeholder="0"
type="number"
{...field}
formatInput={(value) =>
unitConversion("bytesToGb", value)
}
formatOutput={(value) =>
unitConversion("gbToBytes", value)
}
onValueChange={(value) => {
form.setValue(field.name, value as number);
}}
suffix="GB"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="expired_at"
render={({ field }) => (
<FormItem>
<FormLabel>{t("expiredAt", "Expired At")}</FormLabel>
<FormControl>
<DatePicker
onChange={(value: number | null | undefined) => {
if (value === field.value) {
form.setValue(field.name, 0);
} else {
form.setValue(field.name, value!);
}
}}
placeholder={t("permanent", "Permanent")}
value={field.value ?? undefined}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<SheetFooter className="flex-row justify-end gap-2 pt-3">
<Button
disabled={loading}
onClick={() => {
setOpen(false);
}}
variant="outline"
>
{t("cancel", "Cancel")}
</Button>
<Button disabled={loading} onClick={form.handleSubmit(handleSubmit)}>
{loading && (
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
)}
{t("confirm", "Confirm")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}