7400137b3c
Build and Release / Build (push) Has been cancelled
- 新增 NodeBatchSheet 与 ServerBatchSheet,支持批量选中后一键编辑公共字段 - Server 表单:引入 mergeProtocol / buildSanitizedProtocols,过滤空字符串避免 Zod 枚举校验失败;renderGroupCard 改为具名导出供批量 Sheet 复用;优化嵌套错误提示递归取第一条 - 新增 parseDeviceType 工具函数,从 User-Agent 解析设备平台(iPhone/Android/Mac 等) - 用户列表:登录标识旁展示设备平台 Badge - 注册安全设置表单:引入 Textarea 组件并完善字段布局 - 类型定义补充及 i18n 本地化更新 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1720 lines
77 KiB
TypeScript
1720 lines
77 KiB
TypeScript
"use client";
|
|
|
|
import { zodResolver } from "@hookform/resolvers/zod";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import {
|
|
Accordion,
|
|
AccordionContent,
|
|
AccordionItem,
|
|
AccordionTrigger,
|
|
} from "@workspace/ui/components/accordion";
|
|
import { Button } from "@workspace/ui/components/button";
|
|
import { Card } from "@workspace/ui/components/card";
|
|
import { Checkbox } from "@workspace/ui/components/checkbox";
|
|
import {
|
|
Form,
|
|
FormControl,
|
|
FormDescription,
|
|
FormField,
|
|
FormItem,
|
|
FormLabel,
|
|
FormMessage,
|
|
} from "@workspace/ui/components/form";
|
|
import { Label } from "@workspace/ui/components/label";
|
|
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 { Combobox } from "@workspace/ui/composed/combobox";
|
|
import { ArrayInput } from "@workspace/ui/composed/dynamic-inputs";
|
|
import { JSONEditor } from "@workspace/ui/composed/editor/index";
|
|
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
|
|
import { Icon } from "@workspace/ui/composed/icon";
|
|
import {
|
|
getGroupConfig,
|
|
getNodeGroupList,
|
|
} from "@workspace/ui/services/admin/group";
|
|
import {
|
|
evaluateWithPrecision,
|
|
unitConversion,
|
|
} from "@workspace/ui/utils/unit-conversions";
|
|
import { CreditCard, Server, Settings } from "lucide-react";
|
|
import { assign, shake } from "radash";
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { useForm } from "react-hook-form";
|
|
import { useTranslation } from "react-i18next";
|
|
import { toast } from "sonner";
|
|
import { z } from "zod";
|
|
import { useGlobalStore } from "@/stores/global";
|
|
import { useNode } from "@/stores/node";
|
|
|
|
interface SubscribeFormProps<T> {
|
|
onSubmit: (data: T) => Promise<boolean> | boolean;
|
|
initialValues?: T;
|
|
loading?: boolean;
|
|
trigger: string;
|
|
title: string;
|
|
}
|
|
|
|
const defaultValues = {
|
|
inventory: -1,
|
|
speed_limit: 0,
|
|
device_limit: 0,
|
|
traffic: 0,
|
|
quota: 0,
|
|
discount: [],
|
|
language: "",
|
|
node_tags: [],
|
|
nodes: [],
|
|
node_group_id: "",
|
|
node_group_ids: [],
|
|
unit_time: "Month",
|
|
deduction_ratio: 0,
|
|
purchase_with_discount: false,
|
|
reset_cycle: 0,
|
|
renewal_reset: false,
|
|
show_original_price: false,
|
|
deduction_mode: "auto",
|
|
traffic_limit: [],
|
|
};
|
|
|
|
export default function SubscribeForm<T extends Record<string, any>>({
|
|
onSubmit,
|
|
initialValues,
|
|
loading,
|
|
trigger,
|
|
title,
|
|
}: Readonly<SubscribeFormProps<T>>) {
|
|
const { common } = useGlobalStore();
|
|
const { currency } = common;
|
|
|
|
const { t } = useTranslation("product");
|
|
const [open, setOpen] = useState(false);
|
|
const updateTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
|
|
const formSchema = z.object({
|
|
name: z.string(),
|
|
description: z.string().optional(),
|
|
unit_price: z.number(),
|
|
unit_time: z.string(),
|
|
replacement: z.number().optional(),
|
|
discount: z
|
|
.array(
|
|
z.object({
|
|
quantity: z.number(),
|
|
discount: z.number(),
|
|
map_apple: z.string().optional(),
|
|
})
|
|
)
|
|
.optional(),
|
|
inventory: z.number().optional(),
|
|
speed_limit: z.number().optional(),
|
|
device_limit: z.number().optional(),
|
|
traffic: z.number().optional(),
|
|
quota: z.number().optional(),
|
|
language: z.string().optional(),
|
|
node_tags: z.array(z.string()).optional(),
|
|
nodes: z.array(z.number()).optional(),
|
|
node_group_id: z.string().optional(),
|
|
node_group_ids: z.optional(z.array(z.string()).default([])),
|
|
deduction_ratio: z.number().optional(),
|
|
allow_deduction: z.boolean().optional(),
|
|
reset_cycle: z.number().optional(),
|
|
renewal_reset: z.boolean().optional(),
|
|
show_original_price: z.boolean().optional(),
|
|
traffic_limit: z
|
|
.array(
|
|
z.object({
|
|
stat_type: z.string(),
|
|
stat_value: z.number().int(),
|
|
traffic_usage: z.number(),
|
|
speed_limit: z.number(),
|
|
})
|
|
)
|
|
.optional(),
|
|
});
|
|
|
|
const form = useForm<z.infer<typeof formSchema>>({
|
|
resolver: zodResolver(formSchema),
|
|
defaultValues: assign(
|
|
defaultValues,
|
|
shake(initialValues, (value) => value === null) as Record<string, any>
|
|
),
|
|
});
|
|
|
|
const debouncedCalculateDiscount = useCallback(
|
|
(
|
|
values: any[],
|
|
fieldName: string,
|
|
lastChangedField?: string,
|
|
changedIndex?: number
|
|
) => {
|
|
if (updateTimeoutRef.current) {
|
|
clearTimeout(updateTimeoutRef.current);
|
|
}
|
|
|
|
updateTimeoutRef.current = setTimeout(() => {
|
|
const { unit_price } = form.getValues();
|
|
if (!(unit_price && values?.length)) return;
|
|
|
|
let hasChanges = false;
|
|
const calculatedValues = values.map((item: any, index: number) => {
|
|
const result = { ...item };
|
|
|
|
if (changedIndex !== undefined && index !== changedIndex) {
|
|
return result;
|
|
}
|
|
|
|
const quantity = Number(item.quantity) || 0;
|
|
const discount = Number(item.discount) || 0;
|
|
const price = Number(item.price) || 0;
|
|
|
|
switch (lastChangedField) {
|
|
case "quantity":
|
|
case "discount":
|
|
if (quantity > 0 && discount > 0) {
|
|
const newPrice = evaluateWithPrecision(
|
|
`${unit_price} * ${quantity} * ${discount} / 100`
|
|
);
|
|
if (Math.abs(newPrice - price) > 0.01) {
|
|
result.price = newPrice;
|
|
hasChanges = true;
|
|
}
|
|
}
|
|
break;
|
|
|
|
case "price":
|
|
if (quantity > 0 && price > 0) {
|
|
const newDiscount = evaluateWithPrecision(
|
|
`${price} / ${quantity} / ${unit_price} * 100`
|
|
);
|
|
if (Math.abs(newDiscount - discount) > 0.01) {
|
|
result.discount = Math.min(100, Math.max(0, newDiscount));
|
|
hasChanges = true;
|
|
}
|
|
} else if (discount > 0 && price > 0) {
|
|
const newQuantity = evaluateWithPrecision(
|
|
`${price} / ${unit_price} / ${discount} * 100`
|
|
);
|
|
if (
|
|
Math.abs(newQuantity - quantity) > 0.01 &&
|
|
newQuantity > 0
|
|
) {
|
|
result.quantity = Math.max(1, Math.round(newQuantity));
|
|
hasChanges = true;
|
|
}
|
|
}
|
|
break;
|
|
|
|
default:
|
|
if (quantity > 0 && discount > 0 && price === 0) {
|
|
result.price = evaluateWithPrecision(
|
|
`${unit_price} * ${quantity} * ${discount} / 100`
|
|
);
|
|
hasChanges = true;
|
|
} else if (quantity > 0 && price > 0 && discount === 0) {
|
|
const newDiscount = evaluateWithPrecision(
|
|
`${price} / ${quantity} / ${unit_price} * 100`
|
|
);
|
|
result.discount = Math.min(100, Math.max(0, newDiscount));
|
|
hasChanges = true;
|
|
} else if (discount > 0 && price > 0 && quantity === 0) {
|
|
const newQuantity = evaluateWithPrecision(
|
|
`${price} / ${unit_price} / ${discount} * 100`
|
|
);
|
|
if (newQuantity > 0) {
|
|
result.quantity = Math.max(1, Math.round(newQuantity));
|
|
hasChanges = true;
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
return result;
|
|
});
|
|
|
|
if (hasChanges) {
|
|
form.setValue(fieldName as any, calculatedValues, {
|
|
shouldDirty: true,
|
|
});
|
|
}
|
|
}, 300);
|
|
},
|
|
[form]
|
|
);
|
|
|
|
useEffect(() => {
|
|
const processedValues = assign(
|
|
defaultValues,
|
|
shake(initialValues, (value) => value === null) as Record<string, any>
|
|
);
|
|
|
|
// Convert node_group_id from number to string (including 0)
|
|
if (initialValues?.node_group_id !== undefined) {
|
|
processedValues.node_group_id = String(initialValues.node_group_id);
|
|
}
|
|
|
|
// Convert node_group_ids from number[] to string[]
|
|
if (
|
|
initialValues?.node_group_ids &&
|
|
Array.isArray(initialValues.node_group_ids)
|
|
) {
|
|
processedValues.node_group_ids = (
|
|
initialValues.node_group_ids as any[]
|
|
).map((id) => String(id));
|
|
}
|
|
|
|
form?.reset(processedValues);
|
|
const discount = form.getValues("discount") || [];
|
|
if (discount.length > 0) {
|
|
debouncedCalculateDiscount(discount, "discount");
|
|
}
|
|
}, [form, initialValues, open]);
|
|
|
|
useEffect(
|
|
() => () => {
|
|
if (updateTimeoutRef.current) {
|
|
clearTimeout(updateTimeoutRef.current);
|
|
}
|
|
},
|
|
[]
|
|
);
|
|
|
|
async function handleSubmit(data: { [x: string]: any }) {
|
|
// Don't process node_group_id - submit as-is
|
|
|
|
const bool = await onSubmit(data as T);
|
|
if (bool) setOpen(false);
|
|
}
|
|
|
|
const {
|
|
getAllAvailableTags,
|
|
getNodesByTag,
|
|
getNodesWithoutTags,
|
|
getNodesWithoutGroups,
|
|
nodes,
|
|
} = useNode();
|
|
|
|
const tagGroups = getAllAvailableTags();
|
|
|
|
// Fetch node groups (exclude expired groups)
|
|
const { data: nodeGroupsData } = useQuery({
|
|
queryKey: ["nodeGroups"],
|
|
queryFn: async () => {
|
|
const { data } = await getNodeGroupList({ page: 1, size: 1000 });
|
|
const allGroups = data.data?.list || [];
|
|
// Filter out expired node groups
|
|
return allGroups.filter((group) => !group.is_expired_group);
|
|
},
|
|
});
|
|
|
|
// Fetch group config to check if group feature is enabled
|
|
const { data: groupConfigData } = useQuery({
|
|
queryKey: ["groupConfig"],
|
|
queryFn: async () => {
|
|
const { data } = await getGroupConfig();
|
|
return data.data;
|
|
},
|
|
});
|
|
|
|
const isGroupEnabled = groupConfigData?.enabled;
|
|
|
|
const unit_time = form.watch("unit_time");
|
|
const node_group_id = form.watch("node_group_id");
|
|
const node_group_ids = form.watch("node_group_ids");
|
|
|
|
// Watch node_group_id and automatically include it in node_group_ids
|
|
useEffect(() => {
|
|
if (node_group_id) {
|
|
const currentGroupIds = form.getValues("node_group_ids") || [];
|
|
if (!currentGroupIds.includes(node_group_id)) {
|
|
form.setValue("node_group_ids", [...currentGroupIds, node_group_id]);
|
|
}
|
|
}
|
|
}, [node_group_id, form]);
|
|
|
|
// If node_group_id is empty or 0, automatically set it to the first item in node_group_ids
|
|
useEffect(() => {
|
|
if (
|
|
(!node_group_id || node_group_id === "0") &&
|
|
node_group_ids &&
|
|
node_group_ids.length > 0
|
|
) {
|
|
form.setValue("node_group_id", node_group_ids[0]);
|
|
}
|
|
}, [node_group_ids, node_group_id, form]);
|
|
|
|
return (
|
|
<Sheet onOpenChange={setOpen} open={open}>
|
|
<SheetTrigger asChild>
|
|
<Button
|
|
onClick={() => {
|
|
form.reset();
|
|
setOpen(true);
|
|
}}
|
|
>
|
|
{trigger}
|
|
</Button>
|
|
</SheetTrigger>
|
|
<SheetContent className="w-[800px] 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))] px-6">
|
|
<Form {...form}>
|
|
<form className="pt-4" onSubmit={form.handleSubmit(handleSubmit)}>
|
|
<Tabs className="w-full" defaultValue="basic">
|
|
<TabsList className="mb-6 grid w-full grid-cols-4">
|
|
<TabsTrigger
|
|
className="flex items-center gap-2"
|
|
value="basic"
|
|
>
|
|
<Settings className="h-4 w-4" />
|
|
{t("form.basic")}
|
|
</TabsTrigger>
|
|
<TabsTrigger
|
|
className="flex items-center gap-2"
|
|
value="pricing"
|
|
>
|
|
<CreditCard className="h-4 w-4" />
|
|
{t("form.pricing")}
|
|
</TabsTrigger>
|
|
<TabsTrigger
|
|
className="flex items-center gap-2"
|
|
value="servers"
|
|
>
|
|
<Server className="h-4 w-4" />
|
|
{t("form.nodes")}
|
|
</TabsTrigger>
|
|
<TabsTrigger
|
|
className="flex items-center gap-2"
|
|
value="traffic-limit"
|
|
>
|
|
<Icon className="h-4 w-4" icon="uil:tachometer-fast" />
|
|
{t("form.trafficLimit")}
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent className="space-y-4" value="basic">
|
|
<div className="grid gap-6">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<FormField
|
|
control={form.control}
|
|
name="name"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t("form.name")}</FormLabel>
|
|
<FormControl>
|
|
<EnhancedInput
|
|
{...field}
|
|
onValueChange={(value) => {
|
|
form.setValue(field.name, value);
|
|
}}
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
<FormField
|
|
control={form.control}
|
|
name="language"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>
|
|
{t("form.language")}
|
|
<span className="ml-1 text-[0.8rem] text-muted-foreground">
|
|
{t("form.languageDescription")}
|
|
</span>
|
|
</FormLabel>
|
|
<FormControl>
|
|
<EnhancedInput
|
|
{...field}
|
|
onValueChange={(v) =>
|
|
form.setValue(field.name, v as string)
|
|
}
|
|
placeholder={t("form.languagePlaceholder")}
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-3 gap-4">
|
|
<FormField
|
|
control={form.control}
|
|
name="traffic"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t("form.traffic")}</FormLabel>
|
|
<FormControl>
|
|
<EnhancedInput
|
|
placeholder={t("form.noLimit")}
|
|
type="number"
|
|
{...field}
|
|
formatInput={(value) =>
|
|
unitConversion("bytesToGb", value)
|
|
}
|
|
formatOutput={(value) =>
|
|
unitConversion("gbToBytes", value)
|
|
}
|
|
onValueChange={(value) => {
|
|
form.setValue(field.name, value);
|
|
}}
|
|
suffix="GB"
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="speed_limit"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t("form.speedLimit")}</FormLabel>
|
|
<FormControl>
|
|
<EnhancedInput
|
|
placeholder={t("form.noLimit")}
|
|
type="number"
|
|
{...field}
|
|
onValueChange={(value) => {
|
|
form.setValue(field.name, value);
|
|
}}
|
|
suffix="Mbps"
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="device_limit"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t("form.deviceLimit")}</FormLabel>
|
|
<FormControl>
|
|
<EnhancedInput
|
|
placeholder={t("form.noLimit")}
|
|
step={1}
|
|
type="number"
|
|
{...field}
|
|
onValueChange={(value) => {
|
|
form.setValue(field.name, value);
|
|
}}
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-3 gap-4">
|
|
<FormField
|
|
control={form.control}
|
|
name="inventory"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t("form.inventory")}</FormLabel>
|
|
<FormControl>
|
|
<EnhancedInput
|
|
onValueChange={(value) => {
|
|
form.setValue(field.name, value);
|
|
}}
|
|
placeholder={t("form.unlimitedInventory")}
|
|
step={1}
|
|
type="number"
|
|
value={field.value}
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="quota"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t("form.quota")}</FormLabel>
|
|
<FormControl>
|
|
<EnhancedInput
|
|
placeholder={t("form.noLimit")}
|
|
step={1}
|
|
type="number"
|
|
{...field}
|
|
onValueChange={(value) => {
|
|
form.setValue(field.name, value);
|
|
}}
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</div>
|
|
<FormField
|
|
control={form.control}
|
|
name="description"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormControl>
|
|
<JSONEditor
|
|
onChange={(value: any) => {
|
|
form.setValue(
|
|
field.name,
|
|
JSON.stringify(value)
|
|
);
|
|
}}
|
|
placeholder={{
|
|
description: "description",
|
|
features: [
|
|
{
|
|
type: "default",
|
|
icon: "",
|
|
label: "label",
|
|
},
|
|
],
|
|
}}
|
|
schema={{
|
|
type: "object",
|
|
properties: {
|
|
description: {
|
|
type: "string",
|
|
description:
|
|
"A brief description of the item.",
|
|
},
|
|
features: {
|
|
type: "array",
|
|
items: {
|
|
type: "object",
|
|
properties: {
|
|
icon: {
|
|
type: "string",
|
|
description:
|
|
"Enter an Iconify icon identifier (e.g., 'mdi:account').",
|
|
pattern: "^[a-z0-9]+:[a-z0-9-]+$",
|
|
examples: [
|
|
"uil:shield-check",
|
|
"uil:shield-exclamation",
|
|
"uil:database",
|
|
"uil:server",
|
|
],
|
|
},
|
|
label: {
|
|
type: "string",
|
|
description:
|
|
"The label describing the feature.",
|
|
},
|
|
type: {
|
|
type: "string",
|
|
enum: [
|
|
"default",
|
|
"success",
|
|
"destructive",
|
|
],
|
|
description:
|
|
"The type of feature, limited to specific values.",
|
|
},
|
|
},
|
|
},
|
|
description: "A list of feature objects.",
|
|
},
|
|
},
|
|
required: ["description", "features"],
|
|
additionalProperties: false,
|
|
}}
|
|
title={t("form.description")}
|
|
value={field.value && JSON.parse(field.value)}
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
<TabsContent className="space-y-4" value="pricing">
|
|
<div className="grid gap-6">
|
|
<div className="grid grid-cols-4 gap-4">
|
|
<FormField
|
|
control={form.control}
|
|
name="unit_price"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t("form.unitPrice")}</FormLabel>
|
|
<FormControl>
|
|
<EnhancedInput
|
|
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="unit_time"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t("form.unitTime")}</FormLabel>
|
|
<FormControl>
|
|
<Combobox
|
|
placeholder={t("form.selectUnitTime")}
|
|
{...field}
|
|
onChange={(value) => {
|
|
if (value) {
|
|
form.setValue(field.name, value);
|
|
}
|
|
}}
|
|
options={[
|
|
{
|
|
label: t("form.NoLimit"),
|
|
value: "NoLimit",
|
|
},
|
|
{ label: t("form.Year"), value: "Year" },
|
|
{ label: t("form.Month"), value: "Month" },
|
|
{ label: t("form.Day"), value: "Day" },
|
|
{ label: t("form.Hour"), value: "Hour" },
|
|
{ label: t("form.Minute"), value: "Minute" },
|
|
]}
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="replacement"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t("form.replacement")}</FormLabel>
|
|
<FormControl>
|
|
<EnhancedInput
|
|
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="reset_cycle"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t("form.resetCycle")}</FormLabel>
|
|
<FormControl>
|
|
<Combobox<number, false>
|
|
placeholder={t("form.selectResetCycle")}
|
|
{...field}
|
|
onChange={(value) => {
|
|
if (typeof value === "number") {
|
|
form.setValue(field.name, value);
|
|
}
|
|
}}
|
|
options={[
|
|
{ label: t("form.noReset"), value: 0 },
|
|
{ label: t("form.resetOn1st"), value: 1 },
|
|
{ label: t("form.monthlyReset"), value: 2 },
|
|
{ label: t("form.annualReset"), value: 3 },
|
|
]}
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</div>
|
|
<FormField
|
|
control={form.control}
|
|
name="discount"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t("form.discount")}</FormLabel>
|
|
<FormControl>
|
|
<ArrayInput<
|
|
API.SubscribeDiscount & { price?: number }
|
|
>
|
|
fields={[
|
|
{
|
|
name: "quantity",
|
|
type: "number",
|
|
step: 1,
|
|
min: 1,
|
|
suffix: unit_time && t(`form.${unit_time}`),
|
|
},
|
|
{
|
|
name: "discount",
|
|
type: "number",
|
|
min: 1,
|
|
max: 100,
|
|
step: 1,
|
|
placeholder: t("form.discountPercent"),
|
|
suffix: "%",
|
|
},
|
|
{
|
|
name: "price",
|
|
placeholder: t("form.discount_price"),
|
|
type: "number",
|
|
min: 0,
|
|
step: 0.01,
|
|
prefix: currency.currency_symbol,
|
|
formatInput: (value) =>
|
|
unitConversion("centsToDollars", value),
|
|
formatOutput: (value) =>
|
|
unitConversion(
|
|
"dollarsToCents",
|
|
value
|
|
).toString(),
|
|
},
|
|
{
|
|
name: "map_apple",
|
|
type: "text",
|
|
placeholder: t("form.appleProductId"),
|
|
},
|
|
]}
|
|
onChange={(
|
|
newValues: (API.SubscribeDiscount & {
|
|
price?: number;
|
|
})[]
|
|
) => {
|
|
const oldValues = field.value || [];
|
|
let lastChangedField: string | undefined;
|
|
let changedIndex: number | undefined;
|
|
|
|
for (
|
|
let i = 0;
|
|
i <
|
|
Math.max(newValues.length, oldValues.length);
|
|
i++
|
|
) {
|
|
const newItem = newValues[i] || {};
|
|
const oldItem = oldValues[i] || {};
|
|
|
|
if (
|
|
(newItem as any).quantity !==
|
|
(oldItem as any).quantity
|
|
) {
|
|
lastChangedField = "quantity";
|
|
changedIndex = i;
|
|
break;
|
|
}
|
|
if (
|
|
(newItem as any).discount !==
|
|
(oldItem as any).discount
|
|
) {
|
|
lastChangedField = "discount";
|
|
changedIndex = i;
|
|
break;
|
|
}
|
|
if (
|
|
(newItem as any).price !==
|
|
(oldItem as any).price
|
|
) {
|
|
lastChangedField = "price";
|
|
changedIndex = i;
|
|
break;
|
|
}
|
|
}
|
|
form.setValue(field.name, newValues, {
|
|
shouldDirty: true,
|
|
});
|
|
if (newValues?.length > 0) {
|
|
debouncedCalculateDiscount(
|
|
newValues,
|
|
field.name,
|
|
lastChangedField,
|
|
changedIndex
|
|
);
|
|
}
|
|
}}
|
|
value={field.value}
|
|
/>
|
|
</FormControl>
|
|
<FormDescription>
|
|
{t("form.discountDescription")}
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
<FormField
|
|
control={form.control}
|
|
name="deduction_ratio"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t("form.deductionRatio")}</FormLabel>
|
|
<FormControl>
|
|
<EnhancedInput
|
|
type="number"
|
|
{...field}
|
|
max={100}
|
|
min={0}
|
|
onValueChange={(value) => {
|
|
form.setValue(field.name, value);
|
|
}}
|
|
placeholder="Auto"
|
|
suffix="%"
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
<FormDescription>
|
|
{t("form.deductionRatioDescription")}
|
|
</FormDescription>
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
<FormField
|
|
control={form.control}
|
|
name="renewal_reset"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<div className="flex items-center justify-between">
|
|
<div className="space-y-0.5">
|
|
<FormLabel>{t("form.renewalReset")}</FormLabel>
|
|
<FormDescription>
|
|
{t("form.renewalResetDescription")}
|
|
</FormDescription>
|
|
</div>
|
|
<FormControl>
|
|
<Switch
|
|
checked={field.value}
|
|
onCheckedChange={field.onChange}
|
|
/>
|
|
</FormControl>
|
|
</div>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="show_original_price"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<div className="flex items-center justify-between">
|
|
<div className="space-y-0.5">
|
|
<FormLabel>
|
|
{t(
|
|
"form.showOriginalPrice",
|
|
"Show Original Price"
|
|
)}
|
|
</FormLabel>
|
|
<FormDescription>
|
|
{t(
|
|
"form.showOriginalPriceDescription",
|
|
"Display original price in the storefront"
|
|
)}
|
|
</FormDescription>
|
|
</div>
|
|
<FormControl>
|
|
<Switch
|
|
checked={!!field.value}
|
|
onCheckedChange={(value) =>
|
|
form.setValue(field.name, value)
|
|
}
|
|
/>
|
|
</FormControl>
|
|
</div>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="allow_deduction"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<div className="flex items-center justify-between">
|
|
<div className="space-y-0.5">
|
|
<FormLabel>
|
|
{t("form.purchaseWithDiscount")}
|
|
</FormLabel>
|
|
<FormDescription>
|
|
{t("form.purchaseWithDiscountDescription")}
|
|
</FormDescription>
|
|
</div>
|
|
<FormControl>
|
|
<Switch
|
|
checked={!!field.value}
|
|
onCheckedChange={(value) => {
|
|
form.setValue(field.name, value);
|
|
}}
|
|
/>
|
|
</FormControl>
|
|
</div>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
<FormField
|
|
control={form.control}
|
|
name="show_original_price"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<div className="flex items-center justify-between">
|
|
<div className="space-y-0.5">
|
|
<FormLabel>
|
|
{t("form.showOriginalPrice")}
|
|
</FormLabel>
|
|
<FormDescription>
|
|
{t("form.showOriginalPriceDescription")}
|
|
</FormDescription>
|
|
</div>
|
|
<FormControl>
|
|
<Switch
|
|
checked={!!field.value}
|
|
onCheckedChange={(value) => {
|
|
form.setValue(field.name, value);
|
|
}}
|
|
/>
|
|
</FormControl>
|
|
</div>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
<TabsContent className="space-y-4" value="servers">
|
|
<div className="space-y-6">
|
|
{/* Show node_tags field only when group feature is disabled */}
|
|
{!isGroupEnabled && (
|
|
<FormField
|
|
control={form.control}
|
|
name="node_tags"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t("form.nodeGroup")}</FormLabel>
|
|
<FormControl>
|
|
<Accordion
|
|
className="w-full"
|
|
collapsible
|
|
type="single"
|
|
>
|
|
{tagGroups.map((tag) => {
|
|
const value = field.value || [];
|
|
const tagId = tag;
|
|
const nodesWithTag = getNodesByTag(tag);
|
|
|
|
return (
|
|
<AccordionItem
|
|
key={tag}
|
|
value={String(tag)}
|
|
>
|
|
<AccordionTrigger>
|
|
<div className="flex items-center gap-2">
|
|
<Checkbox
|
|
checked={value.includes(
|
|
tagId as any
|
|
)}
|
|
onCheckedChange={(checked) =>
|
|
checked
|
|
? form.setValue(field.name, [
|
|
...value,
|
|
tagId,
|
|
] as any)
|
|
: form.setValue(
|
|
field.name,
|
|
value.filter(
|
|
(v: any) => v !== tagId
|
|
)
|
|
)
|
|
}
|
|
/>
|
|
<Label>
|
|
{tag}
|
|
<span className="ml-2 text-muted-foreground text-xs">
|
|
({nodesWithTag.length})
|
|
</span>
|
|
</Label>
|
|
</div>
|
|
</AccordionTrigger>
|
|
<AccordionContent>
|
|
<ul className="space-y-1">
|
|
{getNodesByTag(tag).map((node) => (
|
|
<li
|
|
className="flex items-center justify-between gap-3"
|
|
key={node.id}
|
|
>
|
|
<span className="flex-1">
|
|
{node.name}
|
|
</span>
|
|
<span className="flex-1">
|
|
{node.address}:{node.port}
|
|
</span>
|
|
<span className="flex-1 text-right">
|
|
{node.protocol}
|
|
</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</AccordionContent>
|
|
</AccordionItem>
|
|
);
|
|
})}
|
|
</Accordion>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
)}
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="nodes"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t("form.node")}</FormLabel>
|
|
<FormControl>
|
|
<div className="flex flex-col gap-2">
|
|
{/* When group feature is enabled, show nodes without groups */}
|
|
{/* When group feature is disabled, show nodes without tags */}
|
|
{(isGroupEnabled
|
|
? getNodesWithoutGroups()
|
|
: getNodesWithoutTags()
|
|
).map((item: API.Node) => {
|
|
const value = field.value || [];
|
|
|
|
return (
|
|
<div
|
|
className="flex items-center gap-2"
|
|
key={item.id}
|
|
>
|
|
<Checkbox
|
|
checked={value.includes(item.id!)}
|
|
onCheckedChange={(checked) =>
|
|
checked
|
|
? form.setValue(field.name, [
|
|
...value,
|
|
item.id,
|
|
])
|
|
: form.setValue(
|
|
field.name,
|
|
value.filter(
|
|
(value: number) =>
|
|
value !== item.id
|
|
)
|
|
)
|
|
}
|
|
/>
|
|
<Label className="flex w-full items-center justify-between gap-3">
|
|
<span className="flex-1">
|
|
{item.name}
|
|
</span>
|
|
<span className="flex-1">
|
|
{item.address}:{item.port}
|
|
</span>
|
|
<span className="flex-1 text-right">
|
|
{item.protocol}
|
|
</span>
|
|
</Label>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</FormControl>
|
|
<FormDescription>
|
|
{isGroupEnabled
|
|
? t(
|
|
"form.nodesWithoutGroupsDescription",
|
|
"Nodes without group assignment will be shown here (nodes that belong to groups are managed in the Node Groups section above)"
|
|
)
|
|
: t(
|
|
"form.nodesDescription",
|
|
"Select nodes for this subscription"
|
|
)}
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
{/* Show node_group_ids field only when group feature is enabled */}
|
|
{isGroupEnabled && (
|
|
<>
|
|
{/* When no default node group is set, show simple node group selection */}
|
|
{node_group_id ? (
|
|
<>
|
|
{/* Default Node Group Selection - shown when default is set */}
|
|
<FormField
|
|
control={form.control}
|
|
name="node_group_id"
|
|
render={({ field }) => {
|
|
// Find the selected node group
|
|
const selectedNodeGroup = nodeGroupsData?.find(
|
|
(g) => String(g.id) === field.value
|
|
);
|
|
// Filter nodes that belong to this group
|
|
const nodesInGroup = selectedNodeGroup
|
|
? (nodes || []).filter((node) => {
|
|
const nodeGroupIds =
|
|
(node as any).node_group_ids || [];
|
|
return nodeGroupIds.includes(
|
|
selectedNodeGroup.id
|
|
);
|
|
})
|
|
: [];
|
|
|
|
return (
|
|
<FormItem>
|
|
<FormLabel>
|
|
{t(
|
|
"form.defaultNodeGroup",
|
|
"Default Node Group"
|
|
)}
|
|
</FormLabel>
|
|
<Card className="p-4">
|
|
<FormControl>
|
|
<Combobox
|
|
onChange={(value) => {
|
|
form.setValue(
|
|
field.name,
|
|
value || ""
|
|
);
|
|
}}
|
|
options={[
|
|
{
|
|
label: t(
|
|
"form.noDefaultNodeGroup",
|
|
"No Default Node Group"
|
|
),
|
|
value: "",
|
|
},
|
|
...(nodeGroupsData?.map((g) => ({
|
|
label: g.name,
|
|
value: String(g.id),
|
|
})) || []),
|
|
]}
|
|
placeholder={t(
|
|
"form.selectDefaultNodeGroup",
|
|
"Select a default node group..."
|
|
)}
|
|
value={field.value}
|
|
/>
|
|
</FormControl>
|
|
<FormDescription className="mt-2">
|
|
{t(
|
|
"form.defaultNodeGroupDescription",
|
|
"The default node group for this product."
|
|
)}
|
|
</FormDescription>
|
|
{/* Show nodes in the selected default node group */}
|
|
{nodesInGroup.length > 0 && (
|
|
<>
|
|
<div className="mt-3 mb-2 text-muted-foreground text-xs">
|
|
{t(
|
|
"form.nodesInGroup",
|
|
"Nodes in this group:"
|
|
)}
|
|
</div>
|
|
<div className="grid grid-cols-1 gap-2">
|
|
{nodesInGroup.map((node) => (
|
|
<div
|
|
className="flex items-center justify-between rounded border bg-muted/30 p-2 text-sm"
|
|
key={node.id}
|
|
>
|
|
<span className="flex-1 font-medium">
|
|
{node.name}
|
|
</span>
|
|
<span className="flex-1 text-muted-foreground">
|
|
{node.address}:{node.port}
|
|
</span>
|
|
<span className="flex-1 text-right text-muted-foreground">
|
|
{node.protocol}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</Card>
|
|
<FormMessage />
|
|
</FormItem>
|
|
);
|
|
}}
|
|
/>
|
|
|
|
{/* Backup Node Groups Selection - filter out default node group */}
|
|
<FormField
|
|
control={form.control}
|
|
name="node_group_ids"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>
|
|
{t(
|
|
"form.backupNodeGroups",
|
|
"Backup Node Groups"
|
|
)}
|
|
</FormLabel>
|
|
<FormControl>
|
|
<div className="space-y-4">
|
|
{nodeGroupsData
|
|
?.filter(
|
|
(g) => String(g.id) !== node_group_id
|
|
)
|
|
?.map((g) => {
|
|
// Filter nodes that belong to this group
|
|
const nodesInGroup = (
|
|
nodes || []
|
|
).filter((node) => {
|
|
const nodeGroupIds =
|
|
(node as any).node_group_ids ||
|
|
[];
|
|
return nodeGroupIds.includes(g.id);
|
|
});
|
|
|
|
return (
|
|
<div
|
|
className="rounded-lg border p-4"
|
|
key={g.id}
|
|
>
|
|
<div className="mb-3 flex items-center space-x-2">
|
|
<Checkbox
|
|
checked={field.value?.includes(
|
|
String(g.id)
|
|
)}
|
|
id={`subscribe-backup-node-group-${g.id}`}
|
|
onCheckedChange={(
|
|
checked
|
|
) => {
|
|
const currentValue =
|
|
field.value || [];
|
|
if (checked) {
|
|
form.setValue(
|
|
field.name,
|
|
[
|
|
...currentValue,
|
|
String(g.id),
|
|
]
|
|
);
|
|
} else {
|
|
form.setValue(
|
|
field.name,
|
|
currentValue.filter(
|
|
(v: string) =>
|
|
v !== String(g.id)
|
|
)
|
|
);
|
|
}
|
|
}}
|
|
/>
|
|
<Label
|
|
className="cursor-pointer font-medium"
|
|
htmlFor={`subscribe-backup-node-group-${g.id}`}
|
|
>
|
|
{g.name}
|
|
<span className="ml-2 text-muted-foreground text-sm">
|
|
({nodesInGroup.length}{" "}
|
|
{t("form.nodes", "nodes")})
|
|
</span>
|
|
</Label>
|
|
</div>
|
|
|
|
{/* Show nodes in this group */}
|
|
{nodesInGroup.length > 0 && (
|
|
<div className="mt-3 ml-6">
|
|
<div className="mb-2 text-muted-foreground text-xs">
|
|
{t(
|
|
"form.nodesInGroup",
|
|
"Nodes in this group:"
|
|
)}
|
|
</div>
|
|
<div className="grid grid-cols-1 gap-2">
|
|
{nodesInGroup.map(
|
|
(node) => (
|
|
<div
|
|
className="flex items-center justify-between rounded border bg-muted/30 p-2 text-sm"
|
|
key={node.id}
|
|
>
|
|
<span className="flex-1 font-medium">
|
|
{node.name}
|
|
</span>
|
|
<span className="flex-1 text-muted-foreground">
|
|
{node.address}:
|
|
{node.port}
|
|
</span>
|
|
<span className="flex-1 text-right text-muted-foreground">
|
|
{node.protocol}
|
|
</span>
|
|
</div>
|
|
)
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</FormControl>
|
|
<FormDescription>
|
|
{t(
|
|
"form.backupNodeGroupsDescription",
|
|
"Select additional backup node groups."
|
|
)}
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</>
|
|
) : (
|
|
<FormField
|
|
control={form.control}
|
|
name="node_group_ids"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>
|
|
{t("form.nodeGroups", "Node Groups")}
|
|
</FormLabel>
|
|
<FormControl>
|
|
<div className="space-y-4">
|
|
{nodeGroupsData?.map((g) => {
|
|
// Filter nodes that belong to this group
|
|
const nodesInGroup = (nodes || []).filter(
|
|
(node) => {
|
|
const nodeGroupIds =
|
|
(node as any).node_group_ids || [];
|
|
return nodeGroupIds.includes(g.id);
|
|
}
|
|
);
|
|
|
|
return (
|
|
<div
|
|
className="rounded-lg border p-4"
|
|
key={g.id}
|
|
>
|
|
<div className="mb-3 flex items-center space-x-2">
|
|
<Checkbox
|
|
checked={field.value?.includes(
|
|
String(g.id)
|
|
)}
|
|
id={`subscribe-node-group-${g.id}`}
|
|
onCheckedChange={(checked) => {
|
|
const currentValue =
|
|
field.value || [];
|
|
const currentDefaultGroupId =
|
|
form.getValues(
|
|
"node_group_id"
|
|
);
|
|
|
|
if (checked) {
|
|
const newValue = [
|
|
...currentValue,
|
|
String(g.id),
|
|
];
|
|
form.setValue(
|
|
field.name,
|
|
newValue
|
|
);
|
|
|
|
// If no default node group is set, set this one as default
|
|
if (!currentDefaultGroupId) {
|
|
form.setValue(
|
|
"node_group_id",
|
|
String(g.id)
|
|
);
|
|
}
|
|
} else {
|
|
form.setValue(
|
|
field.name,
|
|
currentValue.filter(
|
|
(v: string) =>
|
|
v !== String(g.id)
|
|
)
|
|
);
|
|
}
|
|
}}
|
|
/>
|
|
<Label
|
|
className="cursor-pointer font-medium"
|
|
htmlFor={`subscribe-node-group-${g.id}`}
|
|
>
|
|
{g.name}
|
|
<span className="ml-2 text-muted-foreground text-sm">
|
|
({nodesInGroup.length}{" "}
|
|
{t("form.nodes", "nodes")})
|
|
</span>
|
|
</Label>
|
|
</div>
|
|
|
|
{/* Show nodes in this group */}
|
|
{nodesInGroup.length > 0 && (
|
|
<div className="mt-3 ml-6">
|
|
<div className="mb-2 text-muted-foreground text-xs">
|
|
{t(
|
|
"form.nodesInGroup",
|
|
"Nodes in this group:"
|
|
)}
|
|
</div>
|
|
<div className="grid grid-cols-1 gap-2">
|
|
{nodesInGroup.map((node) => (
|
|
<div
|
|
className="flex items-center justify-between rounded border bg-muted/30 p-2 text-sm"
|
|
key={node.id}
|
|
>
|
|
<span className="flex-1 font-medium">
|
|
{node.name}
|
|
</span>
|
|
<span className="flex-1 text-muted-foreground">
|
|
{node.address}:{node.port}
|
|
</span>
|
|
<span className="flex-1 text-right text-muted-foreground">
|
|
{node.protocol}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</FormControl>
|
|
<FormDescription>
|
|
{t(
|
|
"form.nodeGroupsFirstSelectionDescription",
|
|
"Select node groups for this product. The first selected group will be set as the default node group."
|
|
)}
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</TabsContent>
|
|
|
|
{/* Traffic Limit Tab */}
|
|
<TabsContent className="space-y-4" value="traffic-limit">
|
|
<div className="space-y-4">
|
|
<FormField
|
|
control={form.control}
|
|
name="traffic_limit"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>
|
|
{t("form.trafficLimitRules", "Traffic Limit Rules")}
|
|
</FormLabel>
|
|
<FormControl>
|
|
<ArrayInput
|
|
fields={[
|
|
{
|
|
name: "stat_type",
|
|
type: "select",
|
|
placeholder: t(
|
|
"form.statType",
|
|
"Statistics Type"
|
|
),
|
|
value: "day",
|
|
options: [
|
|
{
|
|
label: t("form.statTypeHour", "Hour"),
|
|
value: "hour",
|
|
},
|
|
{
|
|
label: t("form.statTypeDay", "Day"),
|
|
value: "day",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
name: "stat_value",
|
|
type: "number",
|
|
placeholder: t(
|
|
"form.statValue",
|
|
"Time Value"
|
|
),
|
|
min: 1,
|
|
onKeyDown: (
|
|
e: React.KeyboardEvent<HTMLInputElement>
|
|
) => {
|
|
if (e.key === "." || e.key === ",") {
|
|
e.preventDefault();
|
|
}
|
|
},
|
|
formatOutput: (value: string | number) => {
|
|
const num = Number(value);
|
|
return String(
|
|
Number.isNaN(num) ? 0 : Math.floor(num)
|
|
);
|
|
},
|
|
},
|
|
{
|
|
name: "traffic_usage",
|
|
type: "number",
|
|
placeholder: t(
|
|
"form.trafficUsage",
|
|
"Traffic Usage (GB)"
|
|
),
|
|
min: 0,
|
|
onKeyDown: (
|
|
e: React.KeyboardEvent<HTMLInputElement>
|
|
) => {
|
|
if (e.key === "." || e.key === ",") {
|
|
e.preventDefault();
|
|
}
|
|
},
|
|
formatOutput: (value: string | number) => {
|
|
const num = Number(value);
|
|
return String(
|
|
Number.isNaN(num) ? 0 : Math.floor(num)
|
|
);
|
|
},
|
|
},
|
|
{
|
|
name: "speed_limit",
|
|
type: "number",
|
|
placeholder: t(
|
|
"form.speedLimitMbps",
|
|
"Speed Limit (Mbps)"
|
|
),
|
|
min: 0,
|
|
onKeyDown: (
|
|
e: React.KeyboardEvent<HTMLInputElement>
|
|
) => {
|
|
if (e.key === "." || e.key === ",") {
|
|
e.preventDefault();
|
|
}
|
|
},
|
|
formatOutput: (value: string | number) => {
|
|
const num = Number(value);
|
|
return String(
|
|
Number.isNaN(num) ? 0 : Math.floor(num)
|
|
);
|
|
},
|
|
},
|
|
]}
|
|
onChange={(items: any[]) => {
|
|
field.onChange(
|
|
items.map((item) => ({
|
|
...item,
|
|
stat_value: Number(item.stat_value) || 0,
|
|
traffic_usage:
|
|
Number(item.traffic_usage) || 0,
|
|
speed_limit: Number(item.speed_limit) || 0,
|
|
}))
|
|
);
|
|
}}
|
|
value={
|
|
field.value && field.value.length > 0
|
|
? field.value
|
|
: [
|
|
{
|
|
stat_type: "day",
|
|
stat_value: 1,
|
|
traffic_usage: 0,
|
|
speed_limit: 0,
|
|
},
|
|
]
|
|
}
|
|
/>
|
|
</FormControl>
|
|
<FormDescription>
|
|
{t(
|
|
"form.trafficLimitDescription",
|
|
"Configure traffic-based speed limit rules. When traffic usage reaches the specified amount, the speed will be limited."
|
|
)}
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</div>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</form>
|
|
</Form>
|
|
</ScrollArea>
|
|
<SheetFooter className="flex-row justify-end gap-2 pt-3">
|
|
<Button
|
|
disabled={loading}
|
|
onClick={() => {
|
|
setOpen(false);
|
|
}}
|
|
variant="outline"
|
|
>
|
|
{t("form.cancel")}
|
|
</Button>
|
|
<Button
|
|
disabled={loading}
|
|
onClick={form.handleSubmit(handleSubmit, (errors) => {
|
|
const keys = Object.keys(errors);
|
|
for (const key of keys) {
|
|
const formattedKey = key.replace(/_([a-z])/g, (_, letter) =>
|
|
letter.toUpperCase()
|
|
);
|
|
const error = (errors as any)[key];
|
|
toast.error(
|
|
`${t(`form.${formattedKey}`)} is ${error?.message}`
|
|
);
|
|
return false;
|
|
}
|
|
})}
|
|
>
|
|
{loading && (
|
|
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
|
|
)}
|
|
{t("form.confirm")}
|
|
</Button>
|
|
</SheetFooter>
|
|
</SheetContent>
|
|
</Sheet>
|
|
);
|
|
}
|