"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 { onSubmit: (data: T) => Promise | 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>({ onSubmit, initialValues, loading, trigger, title, }: Readonly>) { const { common } = useGlobalStore(); const { currency } = common; const { t } = useTranslation("product"); const [open, setOpen] = useState(false); const updateTimeoutRef = useRef(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>({ resolver: zodResolver(formSchema), defaultValues: assign( defaultValues, shake(initialValues, (value) => value === null) as Record ), }); 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 ); // 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 ( {title}
{t("form.basic")} {t("form.pricing")} {t("form.nodes")} {t("form.trafficLimit")}
( {t("form.name")} { form.setValue(field.name, value); }} /> )} /> ( {t("form.language")} {t("form.languageDescription")} form.setValue(field.name, v as string) } placeholder={t("form.languagePlaceholder")} /> )} />
( {t("form.traffic")} unitConversion("bytesToGb", value) } formatOutput={(value) => unitConversion("gbToBytes", value) } onValueChange={(value) => { form.setValue(field.name, value); }} suffix="GB" /> )} /> ( {t("form.speedLimit")} { form.setValue(field.name, value); }} suffix="Mbps" /> )} /> ( {t("form.deviceLimit")} { form.setValue(field.name, value); }} /> )} />
( {t("form.inventory")} { form.setValue(field.name, value); }} placeholder={t("form.unlimitedInventory")} step={1} type="number" value={field.value} /> )} /> ( {t("form.quota")} { form.setValue(field.name, value); }} /> )} />
( { 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)} /> )} />
( {t("form.unitPrice")} unitConversion("centsToDollars", value) } formatOutput={(value) => unitConversion("dollarsToCents", value) } min={0} onValueChange={(value) => { form.setValue(field.name, value); }} /> )} /> ( {t("form.unitTime")} { 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" }, ]} /> )} /> ( {t("form.replacement")} unitConversion("centsToDollars", value) } formatOutput={(value) => unitConversion("dollarsToCents", value) } min={0} onValueChange={(value) => { form.setValue(field.name, value); }} /> )} /> ( {t("form.resetCycle")} 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 }, ]} /> )} />
( {t("form.discount")} 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} /> {t("form.discountDescription")} )} /> ( {t("form.deductionRatio")} { form.setValue(field.name, value); }} placeholder="Auto" suffix="%" /> {t("form.deductionRatioDescription")} )} /> (
{t("form.renewalReset")} {t("form.renewalResetDescription")}
)} /> (
{t( "form.showOriginalPrice", "Show Original Price" )} {t( "form.showOriginalPriceDescription", "Display original price in the storefront" )}
form.setValue(field.name, value) } />
)} /> (
{t("form.purchaseWithDiscount")} {t("form.purchaseWithDiscountDescription")}
{ form.setValue(field.name, value); }} />
)} /> (
{t("form.showOriginalPrice")} {t("form.showOriginalPriceDescription")}
{ form.setValue(field.name, value); }} />
)} />
{/* Show node_tags field only when group feature is disabled */} {!isGroupEnabled && ( ( {t("form.nodeGroup")} {tagGroups.map((tag) => { const value = field.value || []; const tagId = tag; const nodesWithTag = getNodesByTag(tag); return (
checked ? form.setValue(field.name, [ ...value, tagId, ] as any) : form.setValue( field.name, value.filter( (v: any) => v !== tagId ) ) } />
    {getNodesByTag(tag).map((node) => (
  • {node.name} {node.address}:{node.port} {node.protocol}
  • ))}
); })}
)} /> )} ( {t("form.node")}
{/* 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 (
checked ? form.setValue(field.name, [ ...value, item.id, ]) : form.setValue( field.name, value.filter( (value: number) => value !== item.id ) ) } />
); })}
{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" )}
)} /> {/* 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 */} { // 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 ( {t( "form.defaultNodeGroup", "Default Node Group" )} { 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} /> {t( "form.defaultNodeGroupDescription", "The default node group for this product." )} {/* Show nodes in the selected default node group */} {nodesInGroup.length > 0 && ( <>
{t( "form.nodesInGroup", "Nodes in this group:" )}
{nodesInGroup.map((node) => (
{node.name} {node.address}:{node.port} {node.protocol}
))}
)}
); }} /> {/* Backup Node Groups Selection - filter out default node group */} ( {t( "form.backupNodeGroups", "Backup Node Groups" )}
{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 (
{ 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) ) ); } }} />
{/* Show nodes in this group */} {nodesInGroup.length > 0 && (
{t( "form.nodesInGroup", "Nodes in this group:" )}
{nodesInGroup.map( (node) => (
{node.name} {node.address}: {node.port} {node.protocol}
) )}
)}
); })}
{t( "form.backupNodeGroupsDescription", "Select additional backup node groups." )}
)} /> ) : ( ( {t("form.nodeGroups", "Node Groups")}
{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 (
{ 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) ) ); } }} />
{/* Show nodes in this group */} {nodesInGroup.length > 0 && (
{t( "form.nodesInGroup", "Nodes in this group:" )}
{nodesInGroup.map((node) => (
{node.name} {node.address}:{node.port} {node.protocol}
))}
)}
); })}
{t( "form.nodeGroupsFirstSelectionDescription", "Select node groups for this product. The first selected group will be set as the default node group." )}
)} /> )} )}
{/* Traffic Limit Tab */}
( {t("form.trafficLimitRules", "Traffic Limit Rules")} ) => { 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 ) => { 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 ) => { 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, }, ] } /> {t( "form.trafficLimitDescription", "Configure traffic-based speed limit rules. When traffic usage reaches the specified amount, the speed will be limited." )} )} />
); }