feat(billing): add support for Hetzner and Hostinger server providers with updated pricing and specifications

This commit is contained in:
Mauricio Siu
2025-07-21 00:23:51 -06:00
parent b95dfed8fc
commit 579a2262bf
7 changed files with 1468 additions and 139 deletions

View File

@@ -24,6 +24,7 @@ import {
} from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { ShowProviders } from "./show-providers";
const stripePromise = loadStripe(
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!,
@@ -151,44 +152,25 @@ export const ShowBilling = () => {
<Loader2 className="animate-spin" />
</span>
) : (
<>
{products?.map((product) => {
const featured = true;
return (
<div key={product.id}>
<section
className={clsx(
"flex flex-col rounded-3xl border-dashed border-2 px-4 max-w-sm",
featured
? "order-first border py-8 lg:order-none"
: "lg:py-8",
)}
>
{isAnnual && (
<div className="mb-4 flex flex-row items-center gap-2">
<Badge>Recommended 🚀</Badge>
</div>
)}
{isAnnual ? (
<div className="flex flex-row gap-2 items-center">
<p className="text-2xl font-semibold tracking-tight text-primary ">
${" "}
{calculatePrice(
serverQuantity,
isAnnual,
).toFixed(2)}{" "}
USD
</p>
|
<p className="text-base font-semibold tracking-tight text-muted-foreground">
${" "}
{(
calculatePrice(serverQuantity, isAnnual) / 12
).toFixed(2)}{" "}
/ Month USD
</p>
</div>
) : (
products?.map((product) => {
const featured = true;
return (
<div key={product.id}>
<section
className={clsx(
"flex flex-col rounded-3xl border-dashed border-2 px-4 max-w-sm",
featured
? "order-first border py-8 lg:order-none"
: "lg:py-8",
)}
>
{isAnnual && (
<div className="mb-4 flex flex-row items-center gap-2">
<Badge>Recommended 🚀</Badge>
</div>
)}
{isAnnual ? (
<div className="flex flex-row gap-2 items-center">
<p className="text-2xl font-semibold tracking-tight text-primary ">
${" "}
{calculatePrice(serverQuantity, isAnnual).toFixed(
@@ -196,127 +178,146 @@ export const ShowBilling = () => {
)}{" "}
USD
</p>
)}
<h3 className="mt-5 font-medium text-lg text-primary">
{product.name}
</h3>
<p
className={clsx(
"text-sm",
featured ? "text-white" : "text-slate-400",
)}
>
{product.description}
|
<p className="text-base font-semibold tracking-tight text-muted-foreground">
${" "}
{(
calculatePrice(serverQuantity, isAnnual) / 12
).toFixed(2)}{" "}
/ Month USD
</p>
</div>
) : (
<p className="text-2xl font-semibold tracking-tight text-primary ">
${" "}
{calculatePrice(serverQuantity, isAnnual).toFixed(
2,
)}{" "}
USD
</p>
)}
<h3 className="mt-5 font-medium text-lg text-primary">
{product.name}
</h3>
<p
className={clsx(
"text-sm",
featured ? "text-white" : "text-slate-400",
)}
>
{product.description}
</p>
<ul
className={clsx(
" mt-4 flex flex-col gap-y-2 text-sm",
featured ? "text-white" : "text-slate-200",
<ul
className={clsx(
" mt-4 flex flex-col gap-y-2 text-sm",
featured ? "text-white" : "text-slate-200",
)}
>
{[
"All the features of Dokploy",
"Unlimited deployments",
"Self-hosted on your own infrastructure",
"Full access to all deployment features",
"Dokploy integration",
"Backups",
"All Incoming features",
].map((feature) => (
<li
key={feature}
className="flex text-muted-foreground"
>
<CheckIcon />
<span className="ml-4">{feature}</span>
</li>
))}
</ul>
<div className="flex flex-col gap-2 mt-4">
<div className="flex items-center gap-2 justify-center">
<span className="text-sm text-muted-foreground">
{serverQuantity} Servers
</span>
</div>
<div className="flex items-center space-x-2">
<Button
disabled={serverQuantity <= 1}
variant="outline"
onClick={() => {
if (serverQuantity <= 1) return;
setServerQuantity(serverQuantity - 1);
}}
>
<MinusIcon className="h-4 w-4" />
</Button>
<NumberInput
value={serverQuantity}
onChange={(e) => {
setServerQuantity(
e.target.value as unknown as number,
);
}}
/>
<Button
variant="outline"
onClick={() => {
setServerQuantity(serverQuantity + 1);
}}
>
<PlusIcon className="h-4 w-4" />
</Button>
</div>
<div
className={cn(
data?.subscriptions &&
data?.subscriptions?.length > 0
? "justify-between"
: "justify-end",
"flex flex-row items-center gap-2 mt-4",
)}
>
{[
"All the features of Dokploy",
"Unlimited deployments",
"Self-hosted on your own infrastructure",
"Full access to all deployment features",
"Dokploy integration",
"Backups",
"All Incoming features",
].map((feature) => (
<li
key={feature}
className="flex text-muted-foreground"
>
<CheckIcon />
<span className="ml-4">{feature}</span>
</li>
))}
</ul>
<div className="flex flex-col gap-2 mt-4">
<div className="flex items-center gap-2 justify-center">
<span className="text-sm text-muted-foreground">
{serverQuantity} Servers
</span>
</div>
<div className="flex items-center space-x-2">
{admin?.user.stripeCustomerId && (
<Button
disabled={serverQuantity <= 1}
variant="outline"
onClick={() => {
if (serverQuantity <= 1) return;
variant="secondary"
className="w-full"
onClick={async () => {
const session =
await createCustomerPortalSession();
setServerQuantity(serverQuantity - 1);
window.open(session.url);
}}
>
<MinusIcon className="h-4 w-4" />
Manage Subscription
</Button>
<NumberInput
value={serverQuantity}
onChange={(e) => {
setServerQuantity(
e.target.value as unknown as number,
);
}}
/>
)}
<Button
variant="outline"
onClick={() => {
setServerQuantity(serverQuantity + 1);
}}
>
<PlusIcon className="h-4 w-4" />
</Button>
</div>
<div
className={cn(
data?.subscriptions &&
data?.subscriptions?.length > 0
? "justify-between"
: "justify-end",
"flex flex-row items-center gap-2 mt-4",
)}
>
{admin?.user.stripeCustomerId && (
{data?.subscriptions?.length === 0 && (
<div className="justify-end w-full">
<Button
variant="secondary"
className="w-full"
onClick={async () => {
const session =
await createCustomerPortalSession();
window.open(session.url);
handleCheckout(product.id);
}}
disabled={serverQuantity < 1}
>
Manage Subscription
Subscribe
</Button>
)}
{data?.subscriptions?.length === 0 && (
<div className="justify-end w-full">
<Button
className="w-full"
onClick={async () => {
handleCheckout(product.id);
}}
disabled={serverQuantity < 1}
>
Subscribe
</Button>
</div>
)}
</div>
</div>
)}
</div>
</section>
</div>
);
})}
</>
</div>
</section>
</div>
);
})
)}
</div>
</CardContent>
<div className="flex flex-col gap-4 px-4">
<ShowProviders />
</div>
</div>
</Card>
</div>

View File

@@ -0,0 +1,406 @@
import {
Card,
CardContent,
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";
import {
Select,
SelectContent,
SelectItem,
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);
}
// Function to classify servers by type
function getServerCategory(cpuType: string) {
if (cpuType === "shared") {
return {
category: "Shared CPU",
icon: Cpu,
badge: "shared",
color: "bg-blue-500",
description: "Perfect for small and medium projects",
};
}
return {
category: "Dedicated CPU",
icon: Zap,
badge: "dedicated",
color: "bg-purple-500",
description: "Maximum performance for demanding applications",
};
}
export const ShowHetznerProviders = () => {
const { data: serverTypes, isLoading: isLoadingTypes } =
api.hetzner.serverTypes.useQuery();
const { data: locations, isLoading: isLoadingLocations } =
api.hetzner.locations.useQuery();
const [selectedLocation, setSelectedLocation] = useState<string>("");
const [selectedArchitecture, setSelectedArchitecture] =
useState<string>("x86");
if (isLoadingTypes || isLoadingLocations) {
return (
<div className="flex items-center justify-center p-4">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
);
}
// Get selected location info
const currentLocation = selectedLocation
? locations?.find((loc) => loc.name === selectedLocation)
: locations?.[0]; // Default to first location
// Get available locations from server types
const availableLocations = Array.from(
new Set(
serverTypes?.flatMap((type) => type.prices.map((p) => p.location)) || [],
),
);
// 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);
}) || [];
const renderServerGrid = (
servers: typeof serverTypes,
category: ReturnType<typeof getServerCategory>,
) => {
if (!servers?.length) return null;
const IconComponent = category.icon;
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<IconComponent className="h-5 w-5" />
<h3 className="text-lg font-semibold">{category.category}</h3>
<Badge variant="outline" className={`text-white ${category.color}`}>
{category.badge}
</Badge>
<Badge variant="outline" className="text-xs text-muted-foreground">
Sorted by price
</Badge>
</div>
<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
</div>
</div>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
</div>
);
};
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>
)}
</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>
</div>
<Shield className="h-8 w-8 text-primary" />
</div>
</CardContent>
</Card>
)}
{/* Shared CPU Servers */}
{renderServerGrid(sharedServers, getServerCategory("shared"))}
{/* 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>
</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>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,428 @@
import {
Card,
CardContent,
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";
// Map specs based on plan name
function getVPSSpecs(planName: string) {
const kvmMatch = planName.match(/KVM\s*(\d+)/i);
const kvmNumber = kvmMatch ? Number.parseInt(kvmMatch[1] || "1", 10) : 1;
const specs = {
1: { cpu: 1, ram: 4, storage: 50, bandwidth: 4 },
2: { cpu: 2, ram: 8, storage: 100, bandwidth: 8 },
4: { cpu: 4, ram: 16, storage: 200, bandwidth: 16 },
8: { cpu: 8, ram: 32, storage: 400, bandwidth: 32 },
};
return specs[kvmNumber as keyof typeof specs] || specs[1];
}
// Get description based on plan name
function getPlanDescription(planName: string) {
const kvmMatch = planName.match(/KVM\s*(\d+)/i);
const kvmNumber = kvmMatch ? Number.parseInt(kvmMatch[1] || "1", 10) : 1;
const descriptions = {
1: "Perfect for small projects and personal websites",
2: "Most popular plan for growing businesses",
4: "High performance for demanding applications",
8: "Maximum power for resource-intensive workloads",
};
return (
descriptions[kvmNumber as keyof typeof descriptions] || descriptions[1]
);
}
// Format billing period
function formatBillingPeriod(period: number, unit: string): string {
if (unit === "month") {
return period === 1 ? "Monthly" : `${period} months`;
}
if (unit === "year") {
return period === 1 ? "Yearly" : `${period} years`;
}
return `${period} ${unit}`;
}
// Calculate yearly savings
function calculateSavings(monthlyPrice: number, yearlyPrice: number): number {
return monthlyPrice * 12 - yearlyPrice;
}
export const ShowHostingerServers = () => {
const { data: vpsPlans, isLoading } = api.hostinger.vpsPlans.useQuery();
if (isLoading) {
return (
<div className="flex items-center justify-center p-4">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
);
}
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Server className="h-5 w-5" />
Hostinger VPS Plans
<Badge variant="outline" className="text-xs text-muted-foreground">
Sorted by price
</Badge>
</CardTitle>
<CardDescription>
VPS plans with real pricing from Hostinger API
<br />
<span className="text-xs text-orange-600">
💡 Promotional pricing applies to first billing period only
</span>
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{vpsPlans
?.sort((a, b) => {
// Sort by monthly promotional price (first_period_price)
const monthlyPriceA =
a.prices.find(
(p) => p.period === 1 && p.period_unit === "month",
)?.first_period_price || 0;
const monthlyPriceB =
b.prices.find(
(p) => p.period === 1 && p.period_unit === "month",
)?.first_period_price || 0;
return monthlyPriceA - monthlyPriceB;
})
?.map((plan) => {
const specs = getVPSSpecs(plan.name);
const description = getPlanDescription(plan.name);
return (
<Card
key={plan.id}
className="border-2 hover:border-purple-300 transition-colors 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>
<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>{specs.cpu} vCPU</span>
</div>
<div className="flex items-center gap-1">
<MemoryStick className="h-4 w-4 text-green-500" />
<span>{specs.ram}GB RAM</span>
</div>
<div className="flex items-center gap-1">
<HardDrive className="h-4 w-4 text-orange-500" />
<span>{specs.storage}GB NVMe</span>
</div>
<div className="flex items-center gap-1">
<Globe className="h-4 w-4 text-indigo-500" />
<span>{specs.bandwidth}TB</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 className="text-xs text-gray-600 mt-1">
ID: {price.id}
</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 className="text-xs text-gray-600 mt-1">
ID: {price.id}
</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 className="text-xs text-gray-600 mt-1">
ID: {price.id}
</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}
</span>
</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>
);
})}
</div>
{(!vpsPlans || vpsPlans.length === 0) && (
<div className="text-center py-8 text-muted-foreground">
Could not load VPS plans. Please verify your Hostinger API key.
</div>
)}
<div className="mt-6 p-4 bg-blue-50 rounded-lg border border-blue-200">
<div className="flex items-start gap-2">
<div className="text-blue-600 mt-0.5"></div>
<div className="text-sm text-blue-800">
<strong>For resellers:</strong> These are real prices from
Hostinger API. Promotional pricing applies to the first billing
period only. You can use the shown IDs to create servers via
API.
</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
};

View File

@@ -0,0 +1,47 @@
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { ShowHetznerProviders } from "./show-hetzner-providers";
import { ShowHostingerServers } from "./show-hostinger-servers";
import { DollarSign } from "lucide-react";
export const ShowProviders = () => {
return (
<Card className="w-full bg-transparent ">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<DollarSign className="h-6 w-6 text-green-600" />
Servers
</CardTitle>
<CardDescription>
Manage and view available server types from Hetzner and Hostinger for
your business. Here you can see updated pricing and specifications for
each plan.
</CardDescription>
</CardHeader>
<CardContent>
<Tabs defaultValue="hetzner" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="hetzner" className="flex items-center gap-2">
🇩🇪 Hetzner Cloud
</TabsTrigger>
<TabsTrigger value="hostinger" className="flex items-center gap-2">
🌍 Hostinger VPS
</TabsTrigger>
</TabsList>
<TabsContent value="hetzner" className="mt-4">
<ShowHetznerProviders />
</TabsContent>
<TabsContent value="hostinger" className="mt-4">
<ShowHostingerServers />
</TabsContent>
</Tabs>
</CardContent>
</Card>
);
};

View File

@@ -38,6 +38,8 @@ import { stripeRouter } from "./routers/stripe";
import { swarmRouter } from "./routers/swarm";
import { userRouter } from "./routers/user";
import { volumeBackupsRouter } from "./routers/volume-backups";
import { hetznerRouter } from "./routers/hetzner";
import { hostingerRouter } from "./routers/hostinger";
/**
* This is the primary router for your server.
*
@@ -84,6 +86,8 @@ export const appRouter = createTRPCRouter({
schedule: scheduleRouter,
rollback: rollbackRouter,
volumeBackups: volumeBackupsRouter,
hetzner: hetznerRouter,
hostinger: hostingerRouter,
});
// export type definition of API

View File

@@ -0,0 +1,144 @@
import { createTRPCRouter, protectedProcedure } from "../trpc";
const HETZNER_API_URL = "https://api.hetzner.cloud/v1";
interface HetznerLocation {
id: number;
name: string;
description: string;
country: string;
city: string;
latitude: number;
longitude: number;
network_zone: string;
}
interface HetznerServerType {
id: number;
name: string;
description: string;
cores: number;
memory: number;
disk: number;
prices: {
location: string;
price_hourly: {
net: string;
gross: string;
};
price_monthly: {
net: string;
gross: string;
};
}[];
storage_type: string;
cpu_type: string;
architecture: string;
}
interface HetznerServer {
id: number;
name: string;
status: string;
created: string;
server_type: {
id: number;
name: string;
description: string;
cores: number;
memory: number;
disk: number;
};
public_net: {
ipv4: {
ip: string;
};
ipv6: {
ip: string;
};
};
}
async function fetchHetznerLocations(
apiKey: string,
): Promise<HetznerLocation[]> {
const response = await fetch(`${HETZNER_API_URL}/locations`, {
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error(
`Failed to fetch Hetzner locations: ${response.statusText}`,
);
}
const data = (await response.json()) as { locations?: HetznerLocation[] };
return data.locations || [];
}
async function fetchHetznerServerTypes(
apiKey: string,
): Promise<HetznerServerType[]> {
const response = await fetch(`${HETZNER_API_URL}/server_types`, {
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error(
`Failed to fetch Hetzner server types: ${response.statusText}`,
);
}
const data = (await response.json()) as {
server_types?: HetznerServerType[];
};
return data.server_types || [];
}
async function fetchHetznerServers(apiKey: string): Promise<HetznerServer[]> {
const response = await fetch(`${HETZNER_API_URL}/servers`, {
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error(`Failed to fetch Hetzner servers: ${response.statusText}`);
}
const data = (await response.json()) as { servers?: HetznerServer[] };
return data.servers || [];
}
export const hetznerRouter = createTRPCRouter({
locations: protectedProcedure.query(async () => {
const apiKey = process.env.HETZNER_API_KEY;
if (!apiKey) {
throw new Error("Hetzner API key not configured");
}
return await fetchHetznerLocations(apiKey);
}),
serverTypes: protectedProcedure.query(async () => {
const apiKey = process.env.HETZNER_API_KEY;
if (!apiKey) {
throw new Error("Hetzner API key not configured");
}
return await fetchHetznerServerTypes(apiKey);
}),
servers: protectedProcedure.query(async () => {
const apiKey = process.env.HETZNER_API_KEY;
if (!apiKey) {
throw new Error("Hetzner API key not configured");
}
return await fetchHetznerServers(apiKey);
}),
});

View File

@@ -0,0 +1,299 @@
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { z } from "zod";
const HOSTINGER_API_URL = "https://api.hostinger.com";
interface HostingerCatalogItem {
id: string;
name: string;
category: string;
prices: Array<{
id: string;
name: string;
currency: string;
price: number; // en centavos
first_period_price: number; // precio promocional en centavos
period: number;
period_unit: string;
}>;
}
interface HostingerServer {
id: string;
name: string;
status: string;
created_at: string;
ip_address: string;
plan: {
name: string;
cpu: number;
ram: number;
storage: number;
};
location: string;
}
interface HostingerTemplate {
id: number;
name: string;
description: string;
documentation?: string;
}
interface HostingerDataCenter {
id: number;
name: string;
location: string;
country: string;
}
interface HostingerVMCreateResponse {
order: {
id: number;
subscription_id: string;
status: string;
currency: string;
subtotal: number;
total: number;
};
virtual_machine: {
id: number;
subscription_id: string;
plan: string;
hostname: string;
state: string;
cpus: number;
memory: number;
disk: number;
bandwidth: number;
ipv4: Array<{
id: number;
address: string;
ptr: string;
}>;
};
}
// Obtener catalog items (productos VPS con precios reales)
async function fetchHostingerCatalog(
apiKey: string,
): Promise<HostingerCatalogItem[]> {
const response = await fetch(
`${HOSTINGER_API_URL}/api/billing/v1/catalog?category=VPS`,
{
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
},
);
if (!response.ok) {
throw new Error(
`Failed to fetch Hostinger catalog: ${response.statusText}`,
);
}
const data = (await response.json()) as HostingerCatalogItem[];
return data || [];
}
// Obtener VPS existentes
async function fetchHostingerServers(
apiKey: string,
): Promise<HostingerServer[]> {
const response = await fetch(
`${HOSTINGER_API_URL}/api/vps/v1/virtual-machines`,
{
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
},
);
if (!response.ok) {
// Si no hay servidores o falla, retornamos array vacío
return [];
}
const data = (await response.json()) as { data?: HostingerServer[] };
return data.data || [];
}
// Obtener templates (sistemas operativos) disponibles
async function fetchHostingerTemplates(
apiKey: string,
): Promise<HostingerTemplate[]> {
const response = await fetch(`${HOSTINGER_API_URL}/api/vps/v1/templates`, {
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error(
`Failed to fetch Hostinger templates: ${response.statusText}`,
);
}
const data = (await response.json()) as { data?: HostingerTemplate[] };
return data.data || [];
}
// Obtener data centers disponibles
async function fetchHostingerDataCenters(
apiKey: string,
): Promise<HostingerDataCenter[]> {
const response = await fetch(`${HOSTINGER_API_URL}/api/vps/v1/data-centers`, {
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error(
`Failed to fetch Hostinger data centers: ${response.statusText}`,
);
}
const data = (await response.json()) as { data?: HostingerDataCenter[] };
return data.data || [];
}
// Crear nuevo VPS
async function createHostingerVPS(
apiKey: string,
params: {
item_id: string;
template_id: number;
data_center_id: number;
hostname?: string;
password?: string;
enable_backups?: boolean;
public_key?: {
name: string;
key: string;
};
},
): Promise<HostingerVMCreateResponse> {
const response = await fetch(
`${HOSTINGER_API_URL}/api/vps/v1/virtual-machines`,
{
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
item_id: params.item_id,
setup: {
template_id: params.template_id,
data_center_id: params.data_center_id,
hostname: params.hostname || `vps-${Date.now()}.hstgr.cloud`,
password: params.password || generateRandomPassword(),
enable_backups: params.enable_backups ?? true,
install_monarx: false,
...(params.public_key && { public_key: params.public_key }),
},
coupons: [],
}),
},
);
if (!response.ok) {
const error = await response.text();
throw new Error(
`Failed to create Hostinger VPS: ${response.statusText} - ${error}`,
);
}
return (await response.json()) as HostingerVMCreateResponse;
}
// Generar contraseña aleatoria
function generateRandomPassword(): string {
const chars =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*";
let password = "";
for (let i = 0; i < 16; i++) {
password += chars.charAt(Math.floor(Math.random() * chars.length));
}
return password;
}
export const hostingerRouter = createTRPCRouter({
vpsPlans: protectedProcedure.query(async () => {
const apiKey = process.env.HOSTINGER_API_KEY;
if (!apiKey) {
throw new Error("Hostinger API key not configured");
}
const catalogItems = await fetchHostingerCatalog(apiKey);
return catalogItems.filter((item) => item.name.startsWith("KVM"));
}),
servers: protectedProcedure.query(async () => {
const apiKey = process.env.HOSTINGER_API_KEY;
if (!apiKey) {
return [];
}
return await fetchHostingerServers(apiKey);
}),
templates: protectedProcedure.query(async () => {
const apiKey = process.env.HOSTINGER_API_KEY;
if (!apiKey) {
throw new Error("Hostinger API key not configured");
}
return await fetchHostingerTemplates(apiKey);
}),
dataCenters: protectedProcedure.query(async () => {
const apiKey = process.env.HOSTINGER_API_KEY;
if (!apiKey) {
throw new Error("Hostinger API key not configured");
}
return await fetchHostingerDataCenters(apiKey);
}),
createVPS: protectedProcedure
.input(
z.object({
item_id: z.string(),
template_id: z.number(),
data_center_id: z.number(),
hostname: z.string().optional(),
password: z.string().optional(),
enable_backups: z.boolean().optional(),
ssh_key_name: z.string().optional(),
ssh_key_content: z.string().optional(),
}),
)
.mutation(async ({ input }) => {
const apiKey = process.env.HOSTINGER_API_KEY;
if (!apiKey) {
throw new Error("Hostinger API key not configured");
}
const publicKey =
input.ssh_key_name && input.ssh_key_content
? {
name: input.ssh_key_name,
key: input.ssh_key_content,
}
: undefined;
return await createHostingerVPS(apiKey, {
item_id: input.item_id,
template_id: input.template_id,
data_center_id: input.data_center_id,
hostname: input.hostname,
password: input.password,
enable_backups: input.enable_backups,
public_key: publicKey,
});
}),
});