feat(dashboard): refactor billing settings to use form handling with Zod validation and improve server selection UI

This commit is contained in:
Mauricio Siu
2025-07-31 03:17:45 -06:00
parent e36c665e02
commit ab6cb7349e
3 changed files with 350 additions and 585 deletions

View File

@@ -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>
);
};

View File

@@ -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>

View File

@@ -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;
}),