diff --git a/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx b/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx index 1e0e5d3df..117e980ef 100644 --- a/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx +++ b/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx @@ -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 = () => { ) : ( - <> - {products?.map((product) => { - const featured = true; - return ( -
-
- {isAnnual && ( -
- Recommended 🚀 -
- )} - {isAnnual ? ( -
-

- ${" "} - {calculatePrice( - serverQuantity, - isAnnual, - ).toFixed(2)}{" "} - USD -

- | -

- ${" "} - {( - calculatePrice(serverQuantity, isAnnual) / 12 - ).toFixed(2)}{" "} - / Month USD -

-
- ) : ( + products?.map((product) => { + const featured = true; + return ( +
+
+ {isAnnual && ( +
+ Recommended 🚀 +
+ )} + {isAnnual ? ( +

${" "} {calculatePrice(serverQuantity, isAnnual).toFixed( @@ -196,127 +178,146 @@ export const ShowBilling = () => { )}{" "} USD

- )} -

- {product.name} -

-

- {product.description} + | +

+ ${" "} + {( + calculatePrice(serverQuantity, isAnnual) / 12 + ).toFixed(2)}{" "} + / Month USD +

+
+ ) : ( +

+ ${" "} + {calculatePrice(serverQuantity, isAnnual).toFixed( + 2, + )}{" "} + USD

+ )} +

+ {product.name} +

+

+ {product.description} +

-
    + {[ + "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) => ( +
  • + + {feature} +
  • + ))} +
+
+
+ + {serverQuantity} Servers + +
+ +
+ + { + setServerQuantity( + e.target.value as unknown as number, + ); + }} + /> + + +
+
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) => ( -
  • - - {feature} -
  • - ))} - -
    -
    - - {serverQuantity} Servers - -
    - -
    + {admin?.user.stripeCustomerId && ( - { - setServerQuantity( - e.target.value as unknown as number, - ); - }} - /> + )} - -
    -
    0 - ? "justify-between" - : "justify-end", - "flex flex-row items-center gap-2 mt-4", - )} - > - {admin?.user.stripeCustomerId && ( + {data?.subscriptions?.length === 0 && ( +
    - )} - - {data?.subscriptions?.length === 0 && ( -
    - -
    - )} -
    +
    + )}
    -
    -
    - ); - })} - +
    + + + ); + }) )} +
    + +
    diff --git a/apps/dokploy/components/dashboard/settings/billing/show-hetzner-providers.tsx b/apps/dokploy/components/dashboard/settings/billing/show-hetzner-providers.tsx new file mode 100644 index 000000000..fa171437a --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/billing/show-hetzner-providers.tsx @@ -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(""); + const [selectedArchitecture, setSelectedArchitecture] = + useState("x86"); + + if (isLoadingTypes || isLoadingLocations) { + return ( +
    + +
    + ); + } + + // 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, + ) => { + if (!servers?.length) return null; + + const IconComponent = category.icon; + + return ( +
    +
    + +

    {category.category}

    + + {category.badge} + + + Sorted by price + +
    +

    + {category.description} +

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

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

    +

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

    +
    + +
    +
    +
    + )} + + {/* Shared CPU Servers */} + {renderServerGrid(sharedServers, getServerCategory("shared"))} + + {/* Dedicated CPU Servers */} + {renderServerGrid(dedicatedServers, getServerCategory("dedicated"))} + + {(!serverTypes || serverTypes.length === 0) && ( +
    + Could not load server types. Please verify your Hetzner API key. +
    + )} + + {/* Architecture Information */} +
    +
    +
    + +
    + x86 Architecture: Traditional Intel/AMD + processors. Most compatible with existing software and + applications. Best choice for general-purpose workloads. +
    +
    +
    + +
    +
    + +
    + ARM Architecture: Modern, energy-efficient + processors. Excellent price-to-performance ratio. Perfect for + cloud-native and containerized applications. +
    +
    +
    +
    +
    + ); +}; diff --git a/apps/dokploy/components/dashboard/settings/billing/show-hostinger-servers.tsx b/apps/dokploy/components/dashboard/settings/billing/show-hostinger-servers.tsx new file mode 100644 index 000000000..50b923709 --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/billing/show-hostinger-servers.tsx @@ -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 ( +
    + +
    + ); + } + + return ( +
    + + + + + Hostinger VPS Plans + + Sorted by price + + + + VPS plans with real pricing from Hostinger API +
    + + 💡 Promotional pricing applies to first billing period only + +
    +
    + +
    + {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 ( + + {plan.name === "KVM 2" && ( +
    + MOST POPULAR +
    + )} + + +
    + {plan.name} + VPS +
    + + {description} + +
    + + +
    + {/* Specs */} +
    +
    + + {specs.cpu} vCPU +
    +
    + + {specs.ram}GB RAM +
    +
    + + {specs.storage}GB NVMe +
    +
    + + {specs.bandwidth}TB +
    +
    + + {/* Pricing Options */} +
    +
    + +

    + Billing options: +

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

    + Included features: +

    +
    + {[ + "NVMe SSD storage", + "AMD EPYC processors", + "1000 Mb/s network", + "Free weekly backups", + "DDoS protection", + "Kodee AI assistant", + ].map((feature, index) => ( +
    +
    + + {feature} + +
    + ))} +
    +
    + + {/* Locations */} +
    +

    + Available locations: +

    +
    + {["US", "UK", "NL", "LT", "SG", "BR", "IN"].map( + (location, index) => ( + + {location} + + ), + )} +
    +
    +
    + + + ); + })} +
    + + {(!vpsPlans || vpsPlans.length === 0) && ( +
    + Could not load VPS plans. Please verify your Hostinger API key. +
    + )} + +
    +
    +
    ℹ️
    +
    + For resellers: 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. +
    +
    +
    +
    +
    +
    + ); +}; diff --git a/apps/dokploy/components/dashboard/settings/billing/show-providers.tsx b/apps/dokploy/components/dashboard/settings/billing/show-providers.tsx new file mode 100644 index 000000000..835ffe42e --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/billing/show-providers.tsx @@ -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 ( + + + + + Servers + + + Manage and view available server types from Hetzner and Hostinger for + your business. Here you can see updated pricing and specifications for + each plan. + + + + + + + 🇩🇪 Hetzner Cloud + + + 🌍 Hostinger VPS + + + + + + + + + + + + ); +}; diff --git a/apps/dokploy/server/api/root.ts b/apps/dokploy/server/api/root.ts index e930f2264..97d49501e 100644 --- a/apps/dokploy/server/api/root.ts +++ b/apps/dokploy/server/api/root.ts @@ -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 diff --git a/apps/dokploy/server/api/routers/hetzner.ts b/apps/dokploy/server/api/routers/hetzner.ts new file mode 100644 index 000000000..f07c5e655 --- /dev/null +++ b/apps/dokploy/server/api/routers/hetzner.ts @@ -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 { + 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 { + 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 { + 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); + }), +}); diff --git a/apps/dokploy/server/api/routers/hostinger.ts b/apps/dokploy/server/api/routers/hostinger.ts new file mode 100644 index 000000000..a1737b18b --- /dev/null +++ b/apps/dokploy/server/api/routers/hostinger.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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, + }); + }), +});