mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-20 06:35:22 +02:00
feat(dashboard): implement form handling for server selection with Zod validation and enhance UI components
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
Calendar,
|
||||
Cpu,
|
||||
@@ -8,7 +9,10 @@ import {
|
||||
MemoryStick,
|
||||
Server,
|
||||
} from "lucide-react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -16,9 +20,35 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const formSchema = z.object({
|
||||
datacenter: z.string({
|
||||
required_error: "Please select a datacenter",
|
||||
}),
|
||||
plan: z.string({
|
||||
required_error: "Please select a server plan",
|
||||
}),
|
||||
billingPeriod: z.object(
|
||||
{
|
||||
unit: z.enum(["month", "year"]),
|
||||
period: z.number(),
|
||||
},
|
||||
{
|
||||
required_error: "Please select a billing period",
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
// Format billing period
|
||||
function formatBillingPeriod(period: number, unit: string): string {
|
||||
if (unit === "month") {
|
||||
@@ -30,13 +60,45 @@ function formatBillingPeriod(period: number, unit: string): string {
|
||||
return `${period} ${unit}`;
|
||||
}
|
||||
|
||||
// Calculate yearly savings
|
||||
function calculateSavings(monthlyPrice: number, yearlyPrice: number): number {
|
||||
return monthlyPrice * 12 - yearlyPrice;
|
||||
// Convert price from cents to dollars
|
||||
function formatPrice(priceInCents: number): string {
|
||||
return (priceInCents / 100).toFixed(2);
|
||||
}
|
||||
|
||||
// Calculate yearly savings
|
||||
function calculateSavings(
|
||||
monthlyPriceInCents: number,
|
||||
yearlyPriceInCents: number,
|
||||
): number {
|
||||
return (monthlyPriceInCents * 12 - yearlyPriceInCents) / 100;
|
||||
}
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
export const ShowHostingerServers = () => {
|
||||
const { data: vpsPlans, isLoading } = api.hostinger.vpsPlans.useQuery();
|
||||
const { data: vpsPlans, isLoading: plansLoading } =
|
||||
api.hostinger.vpsPlans.useQuery();
|
||||
const { data: dataCenters, isLoading: centersLoading } =
|
||||
api.hostinger.dataCenters.useQuery();
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
datacenter: "",
|
||||
plan: "",
|
||||
billingPeriod: {
|
||||
unit: "month",
|
||||
period: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const isLoading = plansLoading || centersLoading;
|
||||
|
||||
function onSubmit(data: FormData) {
|
||||
console.log(data);
|
||||
// Handle form submission here
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -66,122 +128,294 @@ export const ShowHostingerServers = () => {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{vpsPlans
|
||||
?.sort((a: any, b: any) => {
|
||||
// Sort by monthly promotional price (first_period_price)
|
||||
const monthlyPriceA =
|
||||
a.prices?.find(
|
||||
(p: any) => p.period === 1 && p.period_unit === "month",
|
||||
)?.first_period_price || 0;
|
||||
const monthlyPriceB =
|
||||
b.prices?.find(
|
||||
(p: any) => p.period === 1 && p.period_unit === "month",
|
||||
)?.first_period_price || 0;
|
||||
return monthlyPriceA - monthlyPriceB;
|
||||
})
|
||||
?.map((plan: any) => {
|
||||
const monthlyPrice =
|
||||
plan.prices?.find(
|
||||
(p: any) => p.period === 1 && p.period_unit === "month",
|
||||
)?.first_period_price || 0;
|
||||
return (
|
||||
<Card
|
||||
key={plan.id}
|
||||
className="border-2 hover:border-purple-300 transition-all duration-200 hover:shadow-lg relative"
|
||||
>
|
||||
{plan.name === "KVM 2" && (
|
||||
<div className="absolute -top-2 -right-2 bg-green-500 text-white px-2 py-1 rounded-full text-xs font-semibold">
|
||||
MOST POPULAR
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
|
||||
{/* Data Center Selection */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="datacenter"
|
||||
render={({ field }) => (
|
||||
<FormItem className="space-y-4">
|
||||
<FormLabel>
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="text-lg font-medium">
|
||||
Select Data Center
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<CardHeader>
|
||||
<CardTitle>{plan.name}</CardTitle>
|
||||
<CardDescription>
|
||||
{"High-performance VPS hosting"}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="flex flex-col items-center p-2 bg-muted rounded-lg">
|
||||
<Cpu className="h-4 w-4 mb-1 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">
|
||||
{plan.metadata?.cpu || 1} vCPU
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Cores
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center p-2 bg-muted rounded-lg">
|
||||
<MemoryStick className="h-4 w-4 mb-1 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">
|
||||
{plan.metadata?.ram || 2} GB
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
RAM
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center p-2 bg-muted rounded-lg">
|
||||
<HardDrive className="h-4 w-4 mb-1 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">
|
||||
{plan.metadata?.disk || 20} GB
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
SSD
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{plan.prices?.map((price: any) => (
|
||||
<div
|
||||
key={`${price.period}-${price.period_unit}`}
|
||||
className="mt-2"
|
||||
>
|
||||
<div className="flex items-center justify-between p-3 bg-primary/5 rounded-lg">
|
||||
<div className="flex items-center">
|
||||
<Calendar className="h-4 w-4 mr-2 text-muted-foreground" />
|
||||
<span className="text-sm">
|
||||
{formatBillingPeriod(
|
||||
price.period || 1,
|
||||
price.period_unit || "month",
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<DollarSign className="h-4 w-4 mr-1 text-primary" />
|
||||
<span className="text-lg font-semibold">
|
||||
${(price.first_period_price || 0).toFixed(2)}
|
||||
</span>
|
||||
{price.period_unit === "year" && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="ml-2 text-xs"
|
||||
>
|
||||
Save $
|
||||
{calculateSavings(
|
||||
monthlyPrice,
|
||||
price.first_period_price || 0,
|
||||
).toFixed(2)}
|
||||
/yr
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4"
|
||||
>
|
||||
{dataCenters?.map((center) => (
|
||||
<FormItem key={center.id}>
|
||||
<FormControl>
|
||||
<RadioGroupItem
|
||||
value={center.id?.toString() || ""}
|
||||
className="peer sr-only"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="p-4 rounded-lg border-2 transition-all duration-200 flex flex-col items-center gap-2 peer-aria-checked:border-purple-500 peer-aria-checked:bg-purple-50 dark:peer-aria-checked:bg-purple-950 hover:border-purple-300 cursor-pointer">
|
||||
<Globe className="h-6 w-6 text-muted-foreground" />
|
||||
<span className="text-sm font-medium text-center">
|
||||
{center.city} / {center.continent}
|
||||
</span>
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{(!vpsPlans || vpsPlans.length === 0) && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Could not load VPS plans. Please retry later.
|
||||
</div>
|
||||
)}
|
||||
{/* Billing Period Selection */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="billingPeriod"
|
||||
render={({ field }) => (
|
||||
<FormItem className="space-y-4">
|
||||
<FormLabel>
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="text-lg font-medium">
|
||||
Billing Period
|
||||
</span>
|
||||
</div>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
onValueChange={(value) => {
|
||||
switch (value) {
|
||||
case "monthly":
|
||||
field.onChange({ unit: "month", period: 1 });
|
||||
break;
|
||||
case "yearly":
|
||||
field.onChange({ unit: "year", period: 1 });
|
||||
break;
|
||||
case "2years":
|
||||
field.onChange({ unit: "year", period: 2 });
|
||||
break;
|
||||
}
|
||||
}}
|
||||
defaultValue="monthly"
|
||||
className="grid w-full grid-cols-3 lg:w-[600px] gap-4"
|
||||
>
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<RadioGroupItem
|
||||
value="monthly"
|
||||
className="peer sr-only"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="flex items-center justify-center p-3 rounded-lg border-2 transition-all duration-200 peer-aria-checked:border-purple-500 peer-aria-checked:bg-purple-50 dark:peer-aria-checked:bg-purple-950 hover:border-purple-300 cursor-pointer">
|
||||
Monthly Billing
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<RadioGroupItem
|
||||
value="yearly"
|
||||
className="peer sr-only"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="flex items-center justify-center p-3 rounded-lg border-2 transition-all duration-200 peer-aria-checked:border-purple-500 peer-aria-checked:bg-purple-50 dark:peer-aria-checked:bg-purple-950 hover:border-purple-300 cursor-pointer">
|
||||
Annual Billing
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<RadioGroupItem
|
||||
value="2years"
|
||||
className="peer sr-only"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="flex items-center justify-center p-3 rounded-lg border-2 transition-all duration-200 peer-aria-checked:border-purple-500 peer-aria-checked:bg-purple-50 dark:peer-aria-checked:bg-purple-950 hover:border-purple-300 cursor-pointer">
|
||||
2 Year Billing
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* VPS Plans Selection */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="plan"
|
||||
render={({ field }) => (
|
||||
<FormItem className="space-y-4">
|
||||
<FormLabel>
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="text-lg font-medium">
|
||||
Select Server Plan
|
||||
</span>
|
||||
</div>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
className="grid grid-cols-1 lg:grid-cols-2 gap-6"
|
||||
>
|
||||
{vpsPlans
|
||||
?.sort((a, b) => {
|
||||
const billingPeriod = form.watch("billingPeriod");
|
||||
const priceA =
|
||||
a.prices?.find(
|
||||
(p) =>
|
||||
p.period_unit === billingPeriod.unit &&
|
||||
p.period === billingPeriod.period,
|
||||
)?.price || 0;
|
||||
const priceB =
|
||||
b.prices?.find(
|
||||
(p) =>
|
||||
p.period_unit === billingPeriod.unit &&
|
||||
p.period === billingPeriod.period,
|
||||
)?.price || 0;
|
||||
return priceA - priceB;
|
||||
})
|
||||
?.map((plan) => {
|
||||
const monthlyPrice =
|
||||
plan.prices?.find(
|
||||
(p) =>
|
||||
p.period === 1 && p.period_unit === "month",
|
||||
)?.price || 0;
|
||||
|
||||
const selectedPrice = plan.prices?.find(
|
||||
(p) =>
|
||||
p.period_unit ===
|
||||
form.watch("billingPeriod.unit") &&
|
||||
p.period === form.watch("billingPeriod.period"),
|
||||
);
|
||||
|
||||
if (!selectedPrice) return null;
|
||||
|
||||
return (
|
||||
<FormItem key={plan.id}>
|
||||
<FormControl>
|
||||
<RadioGroupItem
|
||||
value={plan.id || ""}
|
||||
className="peer sr-only"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="w-full cursor-pointer">
|
||||
<Card
|
||||
className={`border-2 transition-all duration-200 relative bg-transparent hover:border-purple-300 hover:shadow-lg ${field.value === plan.id ? "border-purple-500 bg-purple-950/40" : ""}`}
|
||||
>
|
||||
{plan.name === "KVM 2" && (
|
||||
<div className="absolute -top-2 -right-2 bg-green-500 text-white px-2 py-1 rounded-full text-xs font-semibold">
|
||||
MOST POPULAR
|
||||
</div>
|
||||
)}
|
||||
<CardHeader>
|
||||
<CardTitle>{plan.name}</CardTitle>
|
||||
<CardDescription>
|
||||
{"High-performance VPS hosting"}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="flex flex-col items-center p-2 bg-muted rounded-lg">
|
||||
<Cpu className="h-4 w-4 mb-1 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">
|
||||
{plan.metadata?.cpus || 1} vCPU
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Cores
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center p-2 bg-muted rounded-lg">
|
||||
<MemoryStick className="h-4 w-4 mb-1 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">
|
||||
{Number.parseInt(
|
||||
plan.metadata?.memory || "2048",
|
||||
) / 1024}{" "}
|
||||
GB
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
RAM
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center p-2 bg-muted rounded-lg">
|
||||
<HardDrive className="h-4 w-4 mb-1 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">
|
||||
{Number.parseInt(
|
||||
plan.metadata?.disk_space ||
|
||||
"20480",
|
||||
) / 1024}{" "}
|
||||
GB
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
SSD
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2">
|
||||
<div className="flex items-center justify-between p-3 bg-primary/5 rounded-lg">
|
||||
<div className="flex items-center">
|
||||
<Calendar className="h-4 w-4 mr-2 text-muted-foreground" />
|
||||
<span className="text-sm">
|
||||
{formatBillingPeriod(
|
||||
selectedPrice.period || 1,
|
||||
selectedPrice.period_unit ||
|
||||
"month",
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<DollarSign className="h-4 w-4 mr-1 text-primary" />
|
||||
<span className="text-lg font-semibold">
|
||||
$
|
||||
{formatPrice(
|
||||
selectedPrice.price || 0,
|
||||
)}
|
||||
</span>
|
||||
{selectedPrice.period_unit ===
|
||||
"year" && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="ml-2 text-xs"
|
||||
>
|
||||
Save $
|
||||
{calculateSavings(
|
||||
monthlyPrice,
|
||||
selectedPrice.price || 0,
|
||||
).toFixed(2)}
|
||||
/yr
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
);
|
||||
})}
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit" className="w-full">
|
||||
Create Server
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user