mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-18 13:45:23 +02:00
feat(dashboard): refactor billing settings to use form handling with Zod validation and improve server selection UI
This commit is contained in:
@@ -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<typeof formSchema>;
|
||||
|
||||
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<string>("");
|
||||
const [selectedArchitecture, setSelectedArchitecture] =
|
||||
useState<string>("x86");
|
||||
const form = useForm<FormValues>({
|
||||
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<typeof getServerCategory>,
|
||||
) => {
|
||||
if (!servers?.length) return null;
|
||||
|
||||
if (!servers.length) return null;
|
||||
const IconComponent = category.icon;
|
||||
|
||||
return (
|
||||
@@ -146,261 +133,223 @@ export const ShowHetznerProviders = () => {
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{category.description}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{servers.map((serverType) => {
|
||||
// Find price for selected location
|
||||
const locationPrice = serverType.prices.find(
|
||||
(p) => p.location === activeLocationName,
|
||||
);
|
||||
|
||||
if (!locationPrice) return null;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={serverType.id}
|
||||
className="border-2 hover:border-blue-300 transition-colors bg-transparent"
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex justify-between items-start">
|
||||
<CardTitle className="text-lg">{serverType.name}</CardTitle>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-white ${category.color}`}
|
||||
>
|
||||
{category.badge}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-xs ${serverType.architecture === "arm" ? "bg-green-100 text-green-700" : "bg-blue-100 text-blue-700"}`}
|
||||
>
|
||||
{serverType.architecture.toUpperCase()}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<CardDescription className="text-sm">
|
||||
{serverType.description}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="pt-0">
|
||||
<div className="space-y-3">
|
||||
{/* Specs */}
|
||||
<div className="grid grid-cols-3 gap-2 text-sm">
|
||||
<div className="flex items-center gap-1">
|
||||
<Cpu className="h-4 w-4 text-blue-500" />
|
||||
<span>{serverType.cores} CPU</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<MemoryStick className="h-4 w-4 text-green-500" />
|
||||
<span>{serverType.memory}GB RAM</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<HardDrive className="h-4 w-4 text-orange-500" />
|
||||
<span>{serverType.disk}GB</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pricing for selected location */}
|
||||
<div className="border-t pt-3">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center p-2 bg-green-900 rounded border border-green-600">
|
||||
<span className="text-sm text-green-400 font-medium">
|
||||
Monthly
|
||||
</span>
|
||||
<div className="text-right">
|
||||
<div className="font-bold text-green-400">
|
||||
€{formatPrice(locationPrice.price_monthly.gross)}
|
||||
/mo
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="selectedServerId"
|
||||
render={({ field }) => (
|
||||
<FormItem className="space-y-0">
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
|
||||
>
|
||||
{servers.map((server) => (
|
||||
<div key={server.id} className="relative">
|
||||
<RadioGroupItem
|
||||
value={server.id.toString()}
|
||||
id={`server-${server.id}`}
|
||||
className="absolute right-4 top-4 z-10"
|
||||
/>
|
||||
<label htmlFor={`server-${server.id}`}>
|
||||
<Card
|
||||
className={`relative bg-transparent transition-all duration-200 cursor-pointer ${
|
||||
field.value === server.id.toString()
|
||||
? "border-primary bg-primary/5"
|
||||
: "hover:bg-primary/5"
|
||||
}`}
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle>{server.name}</CardTitle>
|
||||
<CardDescription>
|
||||
{server.description}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Cpu className="h-4 w-4 text-blue-500" />
|
||||
<div>
|
||||
<strong>Cores:</strong> {server.cores}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<MemoryStick className="h-4 w-4 text-green-500" />
|
||||
<div>
|
||||
<strong>Memory:</strong> {server.memory} GB
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<HardDrive className="h-4 w-4 text-purple-500" />
|
||||
<div>
|
||||
<strong>Disk:</strong> {server.disk} GB
|
||||
</div>
|
||||
</div>
|
||||
{/* Show price for selected location */}
|
||||
{server.prices
|
||||
.filter((p) => p.location === selectedLocation)
|
||||
.map((p) => (
|
||||
<div
|
||||
key={p.location}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<EuroIcon className="h-4 w-4 text-yellow-500" />
|
||||
<div>
|
||||
<strong>Price (monthly):</strong> €
|
||||
{Number.parseFloat(
|
||||
p.price_monthly.net,
|
||||
).toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function onSubmit(values: FormValues) {
|
||||
console.log("Form submitted:", values);
|
||||
// Here you can handle the form submission with the selected server
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Region and Architecture Selectors */}
|
||||
<Card className="bg-transparent">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-xl">
|
||||
<MapPin className="h-5 w-5" />
|
||||
Filters
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Choose a region and architecture to see location-specific pricing
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{/* Region Selector */}
|
||||
<div>
|
||||
<span className="text-sm font-medium mb-2 block">Region</span>
|
||||
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center">
|
||||
<div className="flex-1">
|
||||
<Select
|
||||
value={selectedLocation}
|
||||
onValueChange={setSelectedLocation}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select a region" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{validLocations.map((location) => (
|
||||
<SelectItem key={location.name} value={location.name}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{location.name}</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{location.city}, {location.country}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{currentLocation && (
|
||||
<div className="flex flex-col sm:flex-row gap-2 text-sm text-muted-foreground">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Globe className="h-3 w-3" />
|
||||
{currentLocation.description}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{currentLocation.network_zone}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Architecture Selector */}
|
||||
<div>
|
||||
<span className="text-sm font-medium mb-2 block">
|
||||
Architecture
|
||||
</span>
|
||||
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center">
|
||||
<div className="flex-1">
|
||||
<Select
|
||||
value={selectedArchitecture}
|
||||
onValueChange={setSelectedArchitecture}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select architecture" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="x86">
|
||||
<div className="flex items-center gap-2">
|
||||
<Cpu className="h-4 w-4 text-blue-500" />
|
||||
<span className="font-medium">x86 (Intel/AMD)</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Most common
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="arm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Cpu className="h-4 w-4 text-green-500" />
|
||||
<span className="font-medium">ARM</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Energy efficient
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 text-sm">
|
||||
<Badge variant="outline" className="flex items-center gap-1">
|
||||
<span>
|
||||
Architecture:{" "}
|
||||
{selectedArchitecture === "all"
|
||||
? "All"
|
||||
: selectedArchitecture.toUpperCase()}
|
||||
</span>
|
||||
</Badge>
|
||||
{filteredServerTypes && (
|
||||
<Badge variant="outline">
|
||||
{filteredServerTypes.length} servers
|
||||
</Badge>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
{/* Filters Card */}
|
||||
<Card className="bg-transparent">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-xl">
|
||||
<MapPin className="h-5 w-5" />
|
||||
Filters
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Choose a region and architecture to see location-specific pricing
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<Form {...form}>
|
||||
<div className="space-y-6">
|
||||
{/* Region Selector */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="location"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Region</FormLabel>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a region" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{locations.map((loc) => (
|
||||
<SelectItem key={loc.id} value={loc.name}>
|
||||
{loc.description}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormItem>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
/>
|
||||
|
||||
{/* Header with selected region info */}
|
||||
{currentLocation && (
|
||||
<Card className="bg-muted/50">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-primary">
|
||||
Servers in {currentLocation.city}, {currentLocation.country}
|
||||
</h2>
|
||||
<p className="text-sm text-primary">
|
||||
{currentLocation.description} • Zone:{" "}
|
||||
{currentLocation.network_zone}
|
||||
</p>
|
||||
{/* Architecture Selector */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="architecture"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Architecture</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
className="grid grid-cols-2 gap-4"
|
||||
>
|
||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="x86" />
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal">
|
||||
x86 (Intel/AMD)
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="arm" />
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal">ARM</FormLabel>
|
||||
</FormItem>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Shield className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Shared CPU Servers */}
|
||||
{renderServerGrid(sharedServers, getServerCategory("shared"))}
|
||||
{/* Architecture Information */}
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="p-4 bg-blue-900 rounded-lg border border-blue-600">
|
||||
<div className="flex items-start gap-2">
|
||||
<Cpu className="h-5 w-5 text-blue-600 mt-0.5" />
|
||||
<div className="text-sm text-blue-200">
|
||||
<strong>x86 Architecture:</strong> Traditional Intel/AMD
|
||||
processors. Most compatible with existing software and
|
||||
applications. Best choice for general-purpose workloads.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dedicated CPU Servers */}
|
||||
{renderServerGrid(dedicatedServers, getServerCategory("dedicated"))}
|
||||
|
||||
{(!serverTypes || serverTypes.length === 0) && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Could not load server types. Please verify your Hetzner API key.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Architecture Information */}
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="p-4 bg-blue-900 rounded-lg border border-blue-600">
|
||||
<div className="flex items-start gap-2">
|
||||
<Cpu className="h-5 w-5 text-blue-600 mt-0.5" />
|
||||
<div className="text-sm text-blue-200">
|
||||
<strong>x86 Architecture:</strong> Traditional Intel/AMD
|
||||
processors. Most compatible with existing software and
|
||||
applications. Best choice for general-purpose workloads.
|
||||
<div className="p-4 bg-green-900 rounded-lg border border-green-600">
|
||||
<div className="flex items-start gap-2">
|
||||
<Cpu className="h-5 w-5 text-green-600 mt-0.5" />
|
||||
<div className="text-sm text-green-200">
|
||||
<strong>ARM Architecture:</strong> Modern, energy-efficient
|
||||
processors. Excellent price-to-performance ratio. Perfect for
|
||||
cloud-native and containerized applications.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-green-900 rounded-lg border border-green-600">
|
||||
<div className="flex items-start gap-2">
|
||||
<Cpu className="h-5 w-5 text-green-600 mt-0.5" />
|
||||
<div className="text-sm text-green-200">
|
||||
<strong>ARM Architecture:</strong> Modern, energy-efficient
|
||||
processors. Excellent price-to-performance ratio. Perfect for
|
||||
cloud-native and containerized applications.
|
||||
</div>
|
||||
{/* Server Types Grid */}
|
||||
{selectedLocation && (
|
||||
<>
|
||||
{renderServerGrid(sharedServers, getServerCategory("shared"))}
|
||||
{renderServerGrid(dedicatedServers, getServerCategory("dedicated"))}
|
||||
{sharedServers.length === 0 && dedicatedServers.length === 0 && (
|
||||
<p className="text-center text-muted-foreground py-10">
|
||||
No server types available for this region and architecture
|
||||
combination. <br />
|
||||
Please try a different region or architecture.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedLocation && form.watch("selectedServerId") && (
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" className="bg-primary">
|
||||
Continue with Selected Server
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 = () => {
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{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 (
|
||||
<Card
|
||||
key={plan.id}
|
||||
className="border-2 hover:border-purple-300 transition-colors relative"
|
||||
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
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex justify-between items-start">
|
||||
<CardTitle className="text-lg">{plan.name}</CardTitle>
|
||||
<Badge variant="secondary">VPS</Badge>
|
||||
</div>
|
||||
{/* <CardDescription className="text-sm">
|
||||
{description}
|
||||
</CardDescription> */}
|
||||
<CardHeader>
|
||||
<CardTitle>{plan.name}</CardTitle>
|
||||
<CardDescription>
|
||||
{"High-performance VPS hosting"}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="pt-0">
|
||||
<div className="space-y-4">
|
||||
{/* Specs */}
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="flex items-center gap-1">
|
||||
<Cpu className="h-4 w-4 text-blue-500" />
|
||||
<span>{plan.metadata?.cpus} vCPU</span>
|
||||
<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 items-center gap-1">
|
||||
<MemoryStick className="h-4 w-4 text-green-500" />
|
||||
<span>{plan.metadata?.memory}GB RAM</span>
|
||||
<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 items-center gap-1">
|
||||
<HardDrive className="h-4 w-4 text-orange-500" />
|
||||
<span>{plan.metadata?.disk_space}GB NVMe</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Globe className="h-4 w-4 text-indigo-500" />
|
||||
<span>{plan.metadata?.bandwidth}TB</span>
|
||||
<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>
|
||||
|
||||
{/* Pricing Options */}
|
||||
<div className="border-t pt-3">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<DollarSign className="h-4 w-4 text-green-600" />
|
||||
<h4 className="font-medium text-sm">
|
||||
Billing options:
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="monthly" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="monthly" className="text-xs">
|
||||
Monthly
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="yearly" className="text-xs">
|
||||
Yearly
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="biennial" className="text-xs">
|
||||
2 Years
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Monthly prices */}
|
||||
<TabsContent value="monthly" className="mt-2">
|
||||
{plan.prices?.
|
||||
.filter(
|
||||
(p) =>
|
||||
p.period === 1 && p.period_unit === "month",
|
||||
)
|
||||
.map((price) => (
|
||||
<div
|
||||
key={price.id}
|
||||
className="p-2 bg-gray-50 rounded-lg"
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3 text-gray-500" />
|
||||
<span className="text-sm text-gray-600">
|
||||
{formatBillingPeriod(
|
||||
price.period,
|
||||
price.period_unit,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-bold text-green-600">
|
||||
$
|
||||
{(
|
||||
price.first_period_price / 100
|
||||
).toFixed(2)}
|
||||
/mo
|
||||
</div>
|
||||
<div className="text-xs line-through text-gray-500">
|
||||
${(price.price / 100).toFixed(2)}/mo
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</TabsContent>
|
||||
|
||||
{/* Yearly prices */}
|
||||
<TabsContent value="yearly" className="mt-2">
|
||||
{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 (
|
||||
<div
|
||||
key={price.id}
|
||||
className="p-2 bg-blue-50 rounded-lg"
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3 text-blue-500" />
|
||||
<span className="text-sm text-blue-600">
|
||||
{formatBillingPeriod(
|
||||
price.period,
|
||||
price.period_unit,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-bold text-blue-600">
|
||||
$
|
||||
{(
|
||||
price.first_period_price / 100
|
||||
).toFixed(2)}
|
||||
/year
|
||||
</div>
|
||||
<div className="text-xs line-through text-gray-500">
|
||||
${(price.price / 100).toFixed(2)}
|
||||
/year
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{savings > 0 && (
|
||||
<div className="text-xs text-green-600 mt-1">
|
||||
💰 Savings: ${savings.toFixed(2)} vs
|
||||
monthly
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</TabsContent>
|
||||
|
||||
{/* Biennial prices */}
|
||||
<TabsContent value="biennial" className="mt-2">
|
||||
{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 (
|
||||
<div
|
||||
key={price.id}
|
||||
className="p-2 bg-purple-50 rounded-lg"
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3 text-purple-500" />
|
||||
<span className="text-sm text-purple-600">
|
||||
{formatBillingPeriod(
|
||||
price.period,
|
||||
price.period_unit,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-bold text-purple-600">
|
||||
$
|
||||
{(
|
||||
price.first_period_price / 100
|
||||
).toFixed(2)}
|
||||
/2 years
|
||||
</div>
|
||||
<div className="text-xs line-through text-gray-500">
|
||||
${(price.price / 100).toFixed(2)}/2
|
||||
years
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{savings > 0 && (
|
||||
<div className="text-xs text-green-600 mt-1">
|
||||
💰 Savings: ${savings.toFixed(2)} vs
|
||||
monthly
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<div className="border-t pt-3">
|
||||
<h4 className="font-medium text-sm mb-2">
|
||||
Included features:
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 gap-1">
|
||||
{[
|
||||
"NVMe SSD storage",
|
||||
"AMD EPYC processors",
|
||||
"1000 Mb/s network",
|
||||
"Free weekly backups",
|
||||
"DDoS protection",
|
||||
"Kodee AI assistant",
|
||||
].map((feature, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-1 text-xs"
|
||||
>
|
||||
<div className="w-1 h-1 bg-green-500 rounded-full" />
|
||||
<span className="text-muted-foreground">
|
||||
{feature}
|
||||
{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>
|
||||
</div>
|
||||
|
||||
{/* Locations */}
|
||||
<div className="border-t pt-3">
|
||||
<h4 className="font-medium text-sm mb-2">
|
||||
Available locations:
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{["US", "UK", "NL", "LT", "SG", "BR", "IN"].map(
|
||||
(location, index) => (
|
||||
<Badge
|
||||
key={index}
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
>
|
||||
{location}
|
||||
</Badge>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
|
||||
|
||||
Reference in New Issue
Block a user