From ab6cb7349ea7a1524c20aac439f23db07e334cd1 Mon Sep 17 00:00:00 2001 From: Mauricio Siu <47042324+Siumauricio@users.noreply.github.com> Date: Thu, 31 Jul 2025 03:17:45 -0600 Subject: [PATCH] feat(dashboard): refactor billing settings to use form handling with Zod validation and improve server selection UI --- .../billing/show-hetzner-providers.tsx | 573 ++++++++---------- .../billing/show-hostinger-servers.tsx | 359 +++-------- apps/dokploy/server/api/routers/hetzner.ts | 3 +- 3 files changed, 350 insertions(+), 585 deletions(-) diff --git a/apps/dokploy/components/dashboard/settings/billing/show-hetzner-providers.tsx b/apps/dokploy/components/dashboard/settings/billing/show-hetzner-providers.tsx index fa171437a..643b64eb8 100644 --- a/apps/dokploy/components/dashboard/settings/billing/show-hetzner-providers.tsx +++ b/apps/dokploy/components/dashboard/settings/billing/show-hetzner-providers.tsx @@ -1,22 +1,32 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { + Cpu, + EuroIcon, + HardDrive, + Loader2, + MapPin, + MemoryStick, + Zap, +} 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, + CardDescription, CardHeader, CardTitle, - CardDescription, } from "@/components/ui/card"; -import { api } from "@/utils/api"; import { - Loader2, - Cpu, - HardDrive, - MemoryStick, - MapPin, - Globe, - Zap, - Shield, -} from "lucide-react"; -import { Badge } from "@/components/ui/badge"; + Form, + FormControl, + FormField, + FormItem, + FormLabel, +} from "@/components/ui/form"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Select, SelectContent, @@ -24,12 +34,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { useState } from "react"; - -// Function to format prices correctly -function formatPrice(price: string): string { - return Number.parseFloat(price).toFixed(2); -} +import { api } from "@/utils/api"; // Function to classify servers by type function getServerCategory(cpuType: string) { @@ -52,16 +57,33 @@ function getServerCategory(cpuType: string) { }; } -export const ShowHetznerProviders = () => { - const { data: serverTypes, isLoading: isLoadingTypes } = - api.hetzner.serverTypes.useQuery(); +const formSchema = z.object({ + location: z.string().min(1, "Please select a location"), + architecture: z.enum(["x86", "arm"], { + required_error: "Please select an architecture", + }), + selectedServerId: z.string().optional(), +}); - const { data: locations, isLoading: isLoadingLocations } = +type FormValues = z.infer; + +export const ShowHetznerProviders = () => { + const { data: serverTypesData, isLoading: isLoadingTypes } = + api.hetzner.serverTypes.useQuery(); + const { data: locationsData, isLoading: isLoadingLocations } = api.hetzner.locations.useQuery(); - const [selectedLocation, setSelectedLocation] = useState(""); - const [selectedArchitecture, setSelectedArchitecture] = - useState("x86"); + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + location: "", + architecture: "x86", + selectedServerId: "", + }, + }); + + const selectedLocation = form.watch("location"); + const selectedArchitecture = form.watch("architecture"); if (isLoadingTypes || isLoadingLocations) { return ( @@ -71,64 +93,29 @@ export const ShowHetznerProviders = () => { ); } - // Get selected location info - const currentLocation = selectedLocation - ? locations?.find((loc) => loc.name === selectedLocation) - : locations?.[0]; // Default to first location + const locations = locationsData?.locations ?? []; + const serverTypes = serverTypesData?.server_types ?? []; - // Get available locations from server types - const availableLocations = Array.from( - new Set( - serverTypes?.flatMap((type) => type.prices.map((p) => p.location)) || [], - ), + // Filter server types by selected location AND architecture + const filteredServerTypes = serverTypes.filter( + (type) => + type.prices.some((price) => price.location === selectedLocation) && + type.architecture === selectedArchitecture, ); - // Filter locations that exist in both endpoints - const validLocations = - locations?.filter((loc) => availableLocations.includes(loc.name)) || []; - - const activeLocationName = selectedLocation || validLocations[0]?.name || ""; - - // Filter by architecture first, then classify and sort by price - const filteredServerTypes = - serverTypes?.filter((type) => { - if (selectedArchitecture === "all") return true; - return type.architecture === selectedArchitecture; - }) || []; - - // Classify servers by type and sort by monthly price - const sharedServers = - filteredServerTypes - ?.filter((type) => type.cpu_type === "shared") - .sort((a, b) => { - const priceA = - a.prices.find((p) => p.location === activeLocationName)?.price_monthly - .gross || "0"; - const priceB = - b.prices.find((p) => p.location === activeLocationName)?.price_monthly - .gross || "0"; - return Number.parseFloat(priceA) - Number.parseFloat(priceB); - }) || []; - - const dedicatedServers = - filteredServerTypes - ?.filter((type) => type.cpu_type === "dedicated") - .sort((a, b) => { - const priceA = - a.prices.find((p) => p.location === activeLocationName)?.price_monthly - .gross || "0"; - const priceB = - b.prices.find((p) => p.location === activeLocationName)?.price_monthly - .gross || "0"; - return Number.parseFloat(priceA) - Number.parseFloat(priceB); - }) || []; + // Group by CPU type (shared/dedicated) + const sharedServers = filteredServerTypes.filter( + (type) => type.cpu_type === "shared", + ); + const dedicatedServers = filteredServerTypes.filter( + (type) => type.cpu_type === "dedicated", + ); const renderServerGrid = ( servers: typeof serverTypes, category: ReturnType, ) => { - if (!servers?.length) return null; - + if (!servers.length) return null; const IconComponent = category.icon; return ( @@ -146,261 +133,223 @@ export const ShowHetznerProviders = () => {

{category.description}

- -
- {servers.map((serverType) => { - // Find price for selected location - const locationPrice = serverType.prices.find( - (p) => p.location === activeLocationName, - ); - - if (!locationPrice) return null; - - return ( - - -
- {serverType.name} -
- - {category.badge} - - - {serverType.architecture.toUpperCase()} - -
-
- - {serverType.description} - -
- - -
- {/* Specs */} -
-
- - {serverType.cores} CPU -
-
- - {serverType.memory}GB RAM -
-
- - {serverType.disk}GB -
-
- - {/* Pricing for selected location */} -
-
-
- - Monthly - -
-
- €{formatPrice(locationPrice.price_monthly.gross)} - /mo + ( + + + + {servers.map((server) => ( +
+ +
-
-
+ + +
-
- - - ); - })} -
+ ))} + + + + )} + />
); }; + function onSubmit(values: FormValues) { + console.log("Form submitted:", values); + // Here you can handle the form submission with the selected server + } + return ( -
- {/* Region and Architecture Selectors */} - - - - - Filters - - - Choose a region and architecture to see location-specific pricing - - - -
- {/* Region Selector */} -
- Region -
-
- -
- - {currentLocation && ( -
- - - {currentLocation.description} - - - {currentLocation.network_zone} - -
- )} -
-
- - {/* Architecture Selector */} -
- - Architecture - -
-
- -
- -
- - - Architecture:{" "} - {selectedArchitecture === "all" - ? "All" - : selectedArchitecture.toUpperCase()} - - - {filteredServerTypes && ( - - {filteredServerTypes.length} servers - +
+ + {/* Filters Card */} + + + + + Filters + + + Choose a region and architecture to see location-specific pricing + + + + +
+ {/* Region Selector */} + ( + + Region + + )} -
-
-
-
- - + /> - {/* Header with selected region info */} - {currentLocation && ( - - -
-
-

- Servers in {currentLocation.city}, {currentLocation.country} -

-

- {currentLocation.description} • Zone:{" "} - {currentLocation.network_zone} -

+ {/* Architecture Selector */} + ( + + Architecture + + + + + + + + x86 (Intel/AMD) + + + + + + + ARM + + + + + )} + />
- -
+
- )} - {/* Shared CPU Servers */} - {renderServerGrid(sharedServers, getServerCategory("shared"))} + {/* Architecture Information */} +
+
+
+ +
+ x86 Architecture: Traditional Intel/AMD + processors. Most compatible with existing software and + applications. Best choice for general-purpose workloads. +
+
+
- {/* Dedicated CPU Servers */} - {renderServerGrid(dedicatedServers, getServerCategory("dedicated"))} - - {(!serverTypes || serverTypes.length === 0) && ( -
- Could not load server types. Please verify your Hetzner API key. -
- )} - - {/* Architecture Information */} -
-
-
- -
- x86 Architecture: Traditional Intel/AMD - processors. Most compatible with existing software and - applications. Best choice for general-purpose workloads. +
+
+ +
+ ARM Architecture: Modern, energy-efficient + processors. Excellent price-to-performance ratio. Perfect for + cloud-native and containerized applications. +
-
-
- -
- ARM Architecture: Modern, energy-efficient - processors. Excellent price-to-performance ratio. Perfect for - cloud-native and containerized applications. -
+ {/* Server Types Grid */} + {selectedLocation && ( + <> + {renderServerGrid(sharedServers, getServerCategory("shared"))} + {renderServerGrid(dedicatedServers, getServerCategory("dedicated"))} + {sharedServers.length === 0 && dedicatedServers.length === 0 && ( +

+ No server types available for this region and architecture + combination.
+ Please try a different region or architecture. +

+ )} + + )} + + {selectedLocation && form.watch("selectedServerId") && ( +
+
-
-
-
+ )} + + ); }; diff --git a/apps/dokploy/components/dashboard/settings/billing/show-hostinger-servers.tsx b/apps/dokploy/components/dashboard/settings/billing/show-hostinger-servers.tsx index 00039ee5f..a62098bc8 100644 --- a/apps/dokploy/components/dashboard/settings/billing/show-hostinger-servers.tsx +++ b/apps/dokploy/components/dashboard/settings/billing/show-hostinger-servers.tsx @@ -1,23 +1,23 @@ +import { + Calendar, + Cpu, + DollarSign, + Globe, + HardDrive, + Loader2, + MemoryStick, + Server, +} from "lucide-react"; +import { Badge } from "@/components/ui/badge"; import { Card, CardContent, + CardDescription, CardHeader, CardTitle, - CardDescription, } from "@/components/ui/card"; -import { api } from "@/utils/api"; -import { - Loader2, - Server, - Cpu, - HardDrive, - MemoryStick, - Globe, - DollarSign, - Calendar, -} from "lucide-react"; -import { Badge } from "@/components/ui/badge"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { api } from "@/utils/api"; // Format billing period function formatBillingPeriod(period: number, unit: string): string { @@ -68,291 +68,108 @@ export const ShowHostingerServers = () => {
{vpsPlans - ?.sort((a, b) => { + ?.sort((a: any, b: any) => { // Sort by monthly promotional price (first_period_price) const monthlyPriceA = a.prices?.find( - (p) => p.period === 1 && p.period_unit === "month", + (p: any) => p.period === 1 && p.period_unit === "month", )?.first_period_price || 0; const monthlyPriceB = b.prices?.find( - (p) => p.period === 1 && p.period_unit === "month", + (p: any) => p.period === 1 && p.period_unit === "month", )?.first_period_price || 0; return monthlyPriceA - monthlyPriceB; }) - ?.map((plan) => { + ?.map((plan: any) => { + const monthlyPrice = + plan.prices?.find( + (p: any) => p.period === 1 && p.period_unit === "month", + )?.first_period_price || 0; return ( {plan.name === "KVM 2" && (
MOST POPULAR
)} - - -
- {plan.name} - VPS -
- {/* - {description} - */} + + {plan.name} + + {"High-performance VPS hosting"} + - - -
- {/* Specs */} -
-
- - {plan.metadata?.cpus} vCPU + +
+
+
+ + + {plan.metadata?.cpu || 1} vCPU + + + Cores +
-
- - {plan.metadata?.memory}GB RAM +
+ + + {plan.metadata?.ram || 2} GB + + + RAM +
-
- - {plan.metadata?.disk_space}GB NVMe -
-
- - {plan.metadata?.bandwidth}TB +
+ + + {plan.metadata?.disk || 20} GB + + + SSD +
- {/* Pricing Options */} -
-
- -

- Billing options: -

-
- - - - - Monthly - - - Yearly - - - 2 Years - - - - {/* Monthly prices */} - - {plan.prices?. - .filter( - (p) => - p.period === 1 && p.period_unit === "month", - ) - .map((price) => ( -
-
-
- - - {formatBillingPeriod( - price.period, - price.period_unit, - )} - -
-
-
- $ - {( - price.first_period_price / 100 - ).toFixed(2)} - /mo -
-
- ${(price.price / 100).toFixed(2)}/mo -
-
-
-
- ))} -
- - {/* Yearly prices */} - - {plan.prices?. - .filter( - (p) => - p.period === 1 && p.period_unit === "year", - ) - .map((price) => { - const monthlyEquivalent = plan.prices?.find( - (p) => - p.period === 1 && - p.period_unit === "month", - ); - const savings = monthlyEquivalent - ? calculateSavings( - monthlyEquivalent?.price / 100, - price.first_period_price / 100, - ) - : 0; - - return ( -
-
-
- - - {formatBillingPeriod( - price.period, - price.period_unit, - )} - -
-
-
- $ - {( - price.first_period_price / 100 - ).toFixed(2)} - /year -
-
- ${(price.price / 100).toFixed(2)} - /year -
-
-
- {savings > 0 && ( -
- 💰 Savings: ${savings.toFixed(2)} vs - monthly -
- )} -
- ); - })} -
- - {/* Biennial prices */} - - {plan.prices?. - .filter( - (p) => - p.period === 2 && p.period_unit === "year", - ) - .map((price) => { - const monthlyEquivalent = plan.prices?.find( - (p) => - p.period === 1 && - p.period_unit === "month", - ); - const savings = monthlyEquivalent - ? calculateSavings( - (monthlyEquivalent?.price / 100) * 24, - price.first_period_price / 100, - ) - : 0; - - return ( -
-
-
- - - {formatBillingPeriod( - price.period, - price.period_unit, - )} - -
-
-
- $ - {( - price.first_period_price / 100 - ).toFixed(2)} - /2 years -
-
- ${(price.price / 100).toFixed(2)}/2 - years -
-
-
- {savings > 0 && ( -
- 💰 Savings: ${savings.toFixed(2)} vs - monthly -
- )} -
- ); - })} -
-
-
- - {/* Features */} -
-

- Included features: -

-
- {[ - "NVMe SSD storage", - "AMD EPYC processors", - "1000 Mb/s network", - "Free weekly backups", - "DDoS protection", - "Kodee AI assistant", - ].map((feature, index) => ( -
-
- - {feature} + {plan.prices?.map((price: any) => ( +
+
+
+ + + {formatBillingPeriod( + price.period || 1, + price.period_unit || "month", + )}
- ))} +
+ + + ${(price.first_period_price || 0).toFixed(2)} + + {price.period_unit === "year" && ( + + Save $ + {calculateSavings( + monthlyPrice, + price.first_period_price || 0, + ).toFixed(2)} + /yr + + )} +
+
-
- - {/* Locations */} -
-

- Available locations: -

-
- {["US", "UK", "NL", "LT", "SG", "BR", "IN"].map( - (location, index) => ( - - {location} - - ), - )} -
-
+ ))}
diff --git a/apps/dokploy/server/api/routers/hetzner.ts b/apps/dokploy/server/api/routers/hetzner.ts index a4005bd47..f4759779e 100644 --- a/apps/dokploy/server/api/routers/hetzner.ts +++ b/apps/dokploy/server/api/routers/hetzner.ts @@ -1,14 +1,13 @@ import { fetchHetznerLocations, - fetchHetznerServerTypes, fetchHetznerServers, + fetchHetznerServerTypes, } from "@dokploy/server/index"; import { createTRPCRouter, protectedProcedure } from "../trpc"; export const hetznerRouter = createTRPCRouter({ locations: protectedProcedure.query(async () => { const locations = await fetchHetznerLocations(); - console.log(locations); return locations; }),