feat: add Partners page and PartnerForm component

- Introduced a new Partners page with program details and a call-to-action for potential partners.
- Implemented a PartnerForm component for user inquiries, including validation and submission handling.
- Added pricing feature data structure and a PricingFeatureTable component to display pricing options and features.
- Updated UI components for better integration with the new features.
This commit is contained in:
Mauricio Siu
2026-02-18 21:03:15 -06:00
parent a3c781f1d8
commit 8e2e8ddc78
7 changed files with 1281 additions and 345 deletions

View File

@@ -0,0 +1,152 @@
import { Container } from "@/components/Container";
import { PartnerForm } from "@/components/PartnerForm";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import AnimatedGridPattern from "@/components/ui/animated-grid-pattern";
import { Check } from "lucide-react";
import Link from "next/link";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Partners",
description:
"Join the Dokploy partner program. Agency plan, referral program, and reseller options.",
};
const PROGRAMS = [
{
title: "Agency Plan",
badge: "Available",
badgeVariant: "default" as const,
description:
"Premium licensing tier designed for agencies managing multiple clients.",
features: [
"White-label capabilities",
"Unlimited servers",
"Unlimited projects",
"Unlimited organizations",
],
cta: "Get started",
href: "#get-started",
},
{
title: "Referral Program",
badge: "Available",
badgeVariant: "default" as const,
description:
"Earn 20% commission on every customer you refer to Dokploy.",
features: [
"Co-marketing opportunities",
"Partner dashboard",
"Unique referral links",
"20% of first-year revenue",
],
cta: "Get started",
href: "#get-started",
},
{
title: "Reseller Program",
badge: "Coming Soon",
badgeVariant: "secondary" as const,
description:
"Sell Dokploy directly in your market with local presence and relationships.",
features: [
"Strategic market access",
"Cultural advantage",
"Leverage local expertise",
"Market expansion opportunity",
],
cta: "Express interest",
href: "#get-started",
},
];
export default function PartnersPage() {
return (
<div className="relative bg-black">
<AnimatedGridPattern
numSquares={30}
maxOpacity={0.1}
height={40}
width={40}
duration={3}
repeatDelay={1}
className="[mask-image:radial-gradient(800px_circle_at_50%_0%,white,transparent)] absolute inset-x-0 top-0 h-[120%] skew-y-12"
/>
{/* Hero */}
<section className="relative z-10 border-b border-border/30 py-20 sm:py-28">
<Container>
<div className="mx-auto max-w-3xl text-center">
<h1 className="font-display text-3xl font-semibold tracking-tight text-white sm:text-4xl">
Partner with Dokploy
</h1>
<p className="mt-4 text-lg text-muted-foreground">
Join our partner program to unlock premium features, earn revenue
through referrals, and scale your agency operations.
</p>
<Button asChild size="lg" className="mt-8">
<Link href="#get-started">Become a Partner</Link>
</Button>
</div>
</Container>
</section>
{/* Program cards */}
<section className="relative z-10 border-b border-border/30 py-16 sm:py-20">
<Container>
<div className="grid gap-8 md:grid-cols-3">
{PROGRAMS.map((program) => (
<div
key={program.title}
className="flex flex-col rounded-2xl border border-border/50 bg-black/80 p-6"
>
<Badge
variant={program.badgeVariant}
className="mb-4 w-fit"
>
{program.badge}
</Badge>
<h2 className="text-xl font-semibold text-white">
{program.title}
</h2>
<p className="mt-2 text-sm text-muted-foreground">
{program.description}
</p>
<ul className="mt-4 flex flex-col gap-2 text-sm text-muted-foreground">
{program.features.map((f) => (
<li key={f} className="flex items-center gap-2">
<Check className="h-4 w-4 shrink-0 text-primary" />
{f}
</li>
))}
</ul>
<div className="mt-auto pt-6">
<Button asChild variant="outline" className="w-full">
<Link href={program.href}>{program.cta}</Link>
</Button>
</div>
</div>
))}
</div>
</Container>
</section>
{/* Get Started / Form */}
<section id="get-started" className="relative z-10 py-16 sm:py-24">
<Container>
<div className="mx-auto max-w-2xl">
<h2 className="text-center text-2xl font-semibold text-white sm:text-3xl">
Get Started
</h2>
<p className="mt-3 text-center text-muted-foreground">
Join our partner program and start growing with Dokploy.
</p>
<div className="mt-10 rounded-xl border border-border/50 bg-black/80 p-6 sm:p-8">
<PartnerForm />
</div>
</div>
</Container>
</section>
</div>
);
}

View File

@@ -0,0 +1,239 @@
"use client";
import { trackGAEvent } from "@/components/analitycs";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useState } from "react";
const PROGRAM_OPTIONS = [
{ value: "agency", label: "Agency Plan" },
{ value: "referral", label: "Referral Program" },
{ value: "reseller", label: "Reseller Program" },
{ value: "all", label: "All Programs" },
] as const;
interface PartnerFormData {
firstName: string;
lastName: string;
email: string;
company: string;
programInterest: string;
message: string;
}
export function PartnerForm() {
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSubmitted, setIsSubmitted] = useState(false);
const [formData, setFormData] = useState<PartnerFormData>({
firstName: "",
lastName: "",
email: "",
company: "",
programInterest: "",
message: "",
});
const [errors, setErrors] = useState<Record<string, string>>({});
const validate = (): boolean => {
const newErrors: Record<string, string> = {};
if (!formData.firstName.trim()) newErrors.firstName = "Required";
if (!formData.lastName.trim()) newErrors.lastName = "Required";
if (!formData.email.trim()) newErrors.email = "Required";
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
newErrors.email = "Invalid email";
}
if (!formData.company.trim()) newErrors.company = "Required";
if (!formData.programInterest) newErrors.programInterest = "Required";
if (!formData.message.trim()) newErrors.message = "Required";
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validate()) return;
setIsSubmitting(true);
try {
const programLabel =
PROGRAM_OPTIONS.find((o) => o.value === formData.programInterest)
?.label ?? formData.programInterest;
const fullMessage = `Program Interest: ${programLabel}\n\n${formData.message}`;
const response = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
inquiryType: "sales",
firstName: formData.firstName,
lastName: formData.lastName,
email: formData.email,
company: formData.company,
message: fullMessage,
}),
});
if (response.ok) {
trackGAEvent({
action: "Partner Form Submitted",
category: "Partners",
label: formData.programInterest,
});
setIsSubmitted(true);
} else {
throw new Error("Failed to submit");
}
} catch {
setErrors({ message: "Something went wrong. Please try again." });
} finally {
setIsSubmitting(false);
}
};
if (isSubmitted) {
return (
<div className="rounded-xl border border-border/50 bg-muted/10 p-8 text-center">
<h3 className="text-xl font-semibold text-foreground">
Thank you for your interest
</h3>
<p className="mt-2 text-muted-foreground">
We&apos;ve received your message and will get back to you soon.
</p>
</div>
);
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid gap-6 sm:grid-cols-2">
<div className="space-y-2">
<label htmlFor="firstName" className="text-sm font-medium">
First Name <span className="text-destructive">*</span>
</label>
<Input
id="firstName"
placeholder="First name"
value={formData.firstName}
onChange={(e) => {
setFormData((p) => ({ ...p, firstName: e.target.value }));
if (errors.firstName) setErrors((p) => ({ ...p, firstName: "" }));
}}
/>
{errors.firstName && (
<p className="text-sm text-destructive">{errors.firstName}</p>
)}
</div>
<div className="space-y-2">
<label htmlFor="lastName" className="text-sm font-medium">
Last Name <span className="text-destructive">*</span>
</label>
<Input
id="lastName"
placeholder="Last name"
value={formData.lastName}
onChange={(e) => {
setFormData((p) => ({ ...p, lastName: e.target.value }));
if (errors.lastName) setErrors((p) => ({ ...p, lastName: "" }));
}}
/>
{errors.lastName && (
<p className="text-sm text-destructive">{errors.lastName}</p>
)}
</div>
</div>
<div className="space-y-2">
<label htmlFor="email" className="text-sm font-medium">
Email <span className="text-destructive">*</span>
</label>
<Input
id="email"
type="email"
placeholder="you@company.com"
value={formData.email}
onChange={(e) => {
setFormData((p) => ({ ...p, email: e.target.value }));
if (errors.email) setErrors((p) => ({ ...p, email: "" }));
}}
/>
{errors.email && (
<p className="text-sm text-destructive">{errors.email}</p>
)}
</div>
<div className="space-y-2">
<label htmlFor="company" className="text-sm font-medium">
Company <span className="text-destructive">*</span>
</label>
<Input
id="company"
placeholder="Your company"
value={formData.company}
onChange={(e) => {
setFormData((p) => ({ ...p, company: e.target.value }));
if (errors.company) setErrors((p) => ({ ...p, company: "" }));
}}
/>
{errors.company && (
<p className="text-sm text-destructive">{errors.company}</p>
)}
</div>
<div className="space-y-2">
<label htmlFor="programInterest" className="text-sm font-medium">
Program Interest <span className="text-destructive">*</span>
</label>
<Select
value={formData.programInterest}
onValueChange={(value) => {
setFormData((p) => ({ ...p, programInterest: value }));
if (errors.programInterest)
setErrors((p) => ({ ...p, programInterest: "" }));
}}
>
<SelectTrigger>
<SelectValue placeholder="Select program" />
</SelectTrigger>
<SelectContent>
{PROGRAM_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.programInterest && (
<p className="text-sm text-destructive">
{errors.programInterest}
</p>
)}
</div>
<div className="space-y-2">
<label htmlFor="message" className="text-sm font-medium">
Message <span className="text-destructive">*</span>
</label>
<textarea
id="message"
rows={4}
placeholder="Tell us about your goals..."
value={formData.message}
onChange={(e) => {
setFormData((p) => ({ ...p, message: e.target.value }));
if (errors.message) setErrors((p) => ({ ...p, message: "" }));
}}
className="flex w-full resize-none rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
{errors.message && (
<p className="text-sm text-destructive">{errors.message}</p>
)}
</div>
<Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
{isSubmitting ? "Sending..." : "Submit"}
</Button>
</form>
);
}

View File

@@ -0,0 +1,430 @@
"use client";
import clsx from "clsx";
import { cn } from "@/lib/utils";
import { IconInfoCircle } from "@tabler/icons-react";
import {
ArrowRight,
MinusIcon,
PlusCircleIcon,
PlusIcon,
X,
XCircleIcon,
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Container } from "./Container";
import { ContactFormModal } from "./ContactFormModal";
import { Badge } from "./ui/badge";
import AnimatedGradientText from "./ui/animated-gradient-text";
import { Button, buttonVariants } from "./ui/button";
import HeroVideoDialog from "./ui/hero-video-dialog";
import { NumberInput } from "./ui/input";
import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "./ui/tooltip";
function SwirlyDoodle(props: React.ComponentPropsWithoutRef<"svg">) {
return (
<svg
aria-hidden="true"
viewBox="0 0 281 40"
preserveAspectRatio="none"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M240.172 22.994c-8.007 1.246-15.477 2.23-31.26 4.114-18.506 2.21-26.323 2.977-34.487 3.386-2.971.149-3.727.324-6.566 1.523-15.124 6.388-43.775 9.404-69.425 7.31-26.207-2.14-50.986-7.103-78-15.624C10.912 20.7.988 16.143.734 14.657c-.066-.381.043-.344 1.324.456 10.423 6.506 49.649 16.322 77.8 19.468 23.708 2.65 38.249 2.95 55.821 1.156 9.407-.962 24.451-3.773 25.101-4.692.074-.104.053-.155-.058-.135-1.062.195-13.863-.271-18.848-.687-16.681-1.389-28.722-4.345-38.142-9.364-15.294-8.15-7.298-19.232 14.802-20.514 16.095-.934 32.793 1.517 47.423 6.96 13.524 5.033 17.942 12.326 11.463 18.922l-.859.874.697-.006c2.681-.026 15.304-1.302 29.208-2.953 25.845-3.07 35.659-4.519 54.027-7.978 9.863-1.858 11.021-2.048 13.055-2.145a61.901 61.901 0 0 0 4.506-.417c1.891-.259 2.151-.267 1.543-.047-.402.145-2.33.913-4.285 1.707-4.635 1.882-5.202 2.07-8.736 2.903-3.414.805-19.773 3.797-26.404 4.829Zm40.321-9.93c.1-.066.231-.085.29-.041.059.043-.024.096-.183.119-.177.024-.219-.007-.107-.079ZM172.299 26.22c9.364-6.058 5.161-12.039-12.304-17.51-11.656-3.653-23.145-5.47-35.243-5.576-22.552-.198-33.577 7.462-21.321 14.814 12.012 7.205 32.994 10.557 61.531 9.831 4.563-.116 5.372-.288 7.337-1.559Z"
/>
</svg>
);
}
function CheckIcon({
className,
...props
}: React.ComponentPropsWithoutRef<"svg">) {
return (
<svg
aria-hidden="true"
className={clsx(
"h-6 w-6 flex-none fill-current stroke-current",
className,
)}
{...props}
>
<path
d="M9.307 12.248a.75.75 0 1 0-1.114 1.004l1.114-1.004ZM11 15.25l-.557.502a.75.75 0 0 0 1.15-.043L11 15.25Zm4.844-5.041a.75.75 0 0 0-1.188-.918l1.188.918Zm-7.651 3.043 2.25 2.5 1.114-1.004-2.25-2.5-1.114 1.004Zm3.4 2.457 4.25-5.5-1.187-.918-4.25 5.5 1.188.918Z"
strokeWidth={0}
/>
<circle
cx={12}
cy={12}
r={8.25}
fill="none"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export const calculatePrice = (count: number, isAnnual = false) => {
if (isAnnual) {
if (count <= 1) return 45.9;
return 35.7 * count;
}
if (count <= 1) return 4.5;
return count * 3.5;
};
export function PricingLegacy() {
const router = useRouter();
const [isAnnual, setIsAnnual] = useState(false);
const [serverQuantity, setServerQuantity] = useState(1);
const featured = true;
const [openVideo, setOpenVideo] = useState(false);
const [openContactModal, setOpenContactModal] = useState(false);
return (
<section
id="pricing-legacy"
aria-label="Pricing (Legacy)"
className="border-t border-border/30 bg-black py-20 sm:py-32"
>
<div className="absolute inset-0">
<svg viewBox="0 0 2000 1000" xmlns="http://www.w3.org/2000/svg">
<mask id="b" x="0" y="0" width="2000" height="1000">
<path fill="url(#a)" d="M0 0h2000v1000H0z" />
</mask>
<path d="M0 0h2000v1000H0z" />
<g stroke="#22222233" strokeWidth=".4" fill="none" mask="url(#b)">
<path d="M0 0h50v50H0zM50 0h50v50H50zM100 0h50v50h-50zM150 0h50v50h-50zM200 0h50v50h-50zM250 0h50v50h-50zM300 0h50v50h-50zM350 0h50v50h-50zM400 0h50v50h-50zM450 0h50v50h-50zM500 0h50v50h-50zM550 0h50v50h-50zM600 0h50v50h-50zM650 0h50v50h-50zM700 0h50v50h-50zM750 0h50v50h-50zM800 0h50v50h-50zM850 0h50v50h-50zM900 0h50v50h-50zM950 0h50v50h-50zM1000 0h50v50h-50zM1050 0h50v50h-50zM1100 0h50v50h-50zM1150 0h50v50h-50zM1200 0h50v50h-50zM1250 0h50v50h-50zM1300 0h50v50h-50zM1350 0h50v50h-50zM1400 0h50v50h-50zM1450 0h50v50h-50zM1500 0h50v50h-50zM1550 0h50v50h-50zM1600 0h50v50h-50zM1650 0h50v50h-50zM1700 0h50v50h-50zM1750 0h50v50h-50zM1800 0h50v50h-50zM1850 0h50v50h-50zM1900 0h50v50h-50zM1950 0h50v50h-50zM0 50h50v50H0zM50 50h50v50H50zM100 50h50v50h-50zM150 50h50v50h-50zM200 50h50v50h-50zM250 50h50v50h-50zM300 50h50v50h-50zM350 50h50v50h-50zM400 50h50v50h-50zM450 50h50v50h-50zM500 50h50v50h-50zM550 50h50v50h-50zM600 50h50v50h-50zM650 50h50v50h-50zM700 50h50v50h-50zM750 50h50v50h-50zM800 50h50v50h-50zM850 50h50v50h-50zM900 50h50v50h-50zM950 50h50v50h-50zM1000 50h50v50h-50zM1050 50h50v50h-50zM1100 50h50v50h-50zM1150 50h50v50h-50zM1200 50h50v50h-50zM1250 50h50v50h-50zM1300 50h50v50h-50zM1350 50h50v50h-50zM1400 50h50v50h-50zM1450 50h50v50h-50zM1500 50h50v50h-50zM1550 50h50v50h-50zM1600 50h50v50h-50zM1650 50h50v50h-50zM1700 50h50v50h-50zM1750 50h50v50h-50zM1800 50h50v50h-50zM1850 50h50v50h-50zM1900 50h50v50h-50zM1950 50h50v50h-50z" />
</g>
<defs>
<radialGradient id="a">
<stop offset="50%" stopColor="#fff" stopOpacity="0" />
<stop offset="1" stopColor="#fff" stopOpacity="1" />
</radialGradient>
</defs>
</svg>
</div>
<Container className="relative">
<div className="text-center">
<h2 className="font-display text-3xl tracking-tight text-white sm:text-4xl">
<span className="relative whitespace-nowrap">
<SwirlyDoodle className="absolute left-0 top-1/2 h-[1em] w-full fill-muted-foreground" />
<span className="relative">Simple Affordable</span>
</span>{" "}
Pricing.
</h2>
<p className="mt-4 text-lg text-muted-foreground">
Deploy Smarter and Scale Faster, Without Breaking the Bank
</p>
</div>
<div className=" mx-auto mt-10">
<div className="mx-auto mt-16 flex w-full flex-col items-center justify-center gap-10 lg:-mx-8 xl:mx-0 xl:gap-x-8">
<Tabs
defaultValue="monthly"
value={isAnnual ? "annual" : "monthly"}
onValueChange={(e) => setIsAnnual(e === "annual")}
>
<TabsList>
<TabsTrigger value="monthly">Monthly</TabsTrigger>
<TabsTrigger value="annual">Annual</TabsTrigger>
</TabsList>
</Tabs>
<div className="mx-auto flex max-w-4xl flex-col gap-8">
<div className="flex gap-4 max-sm:flex-wrap max-sm:justify-center sm:flex-row">
<section
className={clsx(
"flex max-w-sm flex-col rounded-3xl border-2 border-dashed border-muted px-4",
featured
? "order-first border bg-black py-8 lg:order-none"
: "lg:py-8",
)}
>
<div className="flex flex-row items-center gap-2">
<p className=" text-2xl font-semibold tracking-tight text-primary ">
Free
</p>
|
<p className=" text-base font-semibold tracking-tight text-muted-foreground">
Open Source
</p>
</div>
<h3 className="mt-5 text-lg font-medium text-white">
Dokploy Open Source
</h3>
<p
className={clsx(
"text-sm",
featured ? "text-white" : "text-slate-400",
)}
>
Install and manage Dokploy UI on your own server.
</p>
<ul
role="list"
className={clsx(
" mt-4 flex flex-col gap-y-2 text-sm",
featured ? "text-white" : "text-slate-200",
)}
>
{[
"Complete Flexibility: Install Dokploy UI on your own infrastructure",
"Self-hosted Infrastructure",
"Community Support",
"Access to Core Features",
"Access to All Updates",
"Unlimited Servers",
].map((feature) => (
<li key={feature} className="flex text-muted-foreground">
<CheckIcon />
<span className="ml-2">{feature}</span>
</li>
))}
<li className="flex text-muted-foreground">
<XCircleIcon className="size-5 self-center text-destructive" />
<span className="ml-3 text-destructive">
Remote Servers Monitoring
</span>
</li>
</ul>
<div className="mt-4 flex flex-col gap-2">
<div className="flex flex-col items-center justify-center gap-2">
<span className="text-sm text-muted-foreground">
Unlimited Servers
</span>
<Link
href="https://docs.dokploy.com/docs/core/installation#docker"
target="_blank"
className="flex items-start text-sm text-primary"
>
Start deploying{" "}
<ArrowRight className="ml-2 size-4 self-center" />
</Link>
</div>
</div>
</section>
<section
className={clsx(
"flex max-w-sm flex-col rounded-3xl border-2 border-dashed px-4",
featured
? "order-first border bg-black 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 items-center gap-2">
<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>
) : (
<p className=" text-2xl font-semibold tracking-tight text-primary">
$ {calculatePrice(serverQuantity, isAnnual).toFixed(2)} USD
</p>
)}
<h3 className="mt-5 text-lg font-medium text-white">
Dokploy Plan
</h3>
<p
className={clsx(
"text-sm",
featured ? "text-white" : "text-slate-400",
)}
>
We manage the Dokploy UI infrastructure, we take care of it
for you.
</p>
<ul
role="list"
className={clsx(
" mt-4 flex flex-col gap-y-2 text-sm",
featured ? "text-white" : "text-slate-200",
)}
>
{[
"Managed Hosting: No need to manage your own servers",
"Unlimited Deployments",
"Unlimited Databases",
"Unlimited Applications",
"Unlimited Users",
"Remote Servers Monitoring",
"Priority Support",
].map((feature, index) => (
<li
key={`${feature}-${index}`}
className="flex text-muted-foreground"
>
<CheckIcon />
<span className="ml-2">{feature}</span>
</li>
))}
</ul>
<div className="mt-4 flex flex-col gap-2">
<div className="flex items-center justify-center gap-2">
<span className="text-sm text-muted-foreground">
No. of {serverQuantity} Servers (You bring the servers)
</span>
<TooltipProvider>
<Tooltip open={openVideo}>
<TooltipTrigger onClick={() => setOpenVideo(true)}>
<IconInfoCircle className="size-5 text-muted-foreground transition-colors hover:text-primary " />
</TooltipTrigger>
<TooltipContent className=" z-[200] w-[400px] rounded-lg text-center font-semibold text-white">
<div className="mb-2 flex w-full justify-end self-end text-muted-foreground transition-colors hover:text-primary">
<X
onClick={() => setOpenVideo(false)}
className="flex size-4 cursor-pointer self-end text-muted-foreground transition-colors hover:text-primary"
/>
</div>
<p className="mb-2 text-left text-primary">
We recommend you to watch the video to understand
the benefits of Dokploy Cloud
</p>
<HeroVideoDialog
className="z-20 block w-full max-w-md rounded-xl"
animationStyle="top-in-bottom-out"
videoSrc="https://www.youtube.com/embed/x2s_Y5ON-ms?si=i6gntgMmyPDLuPih"
thumbnailSrc="https://dokploy.com/banner.png"
thumbnailAlt="Hero Video"
/>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</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(
"justify-between",
"mt-4 flex flex-row items-center gap-2",
)}
>
<div className="w-full justify-end">
<Link
href="https://app.dokploy.com/register"
target="_blank"
className={buttonVariants({
className: "w-full",
})}
>
Subscribe
</Link>
</div>
</div>
</div>
</section>
</div>
<div className="flex justify-center">
<section
className={clsx(
"flex w-full max-w-4xl flex-col rounded-3xl border-2 border-dashed border-muted px-4 py-4",
)}
>
<div className="flex flex-row items-center justify-start gap-3 w-fit">
<p className="text-xl font-semibold tracking-tight text-primary">
Enterprise
</p>
<AnimatedGradientText className="text-xs">
Premium
</AnimatedGradientText>
</div>
<h3 className="mt-3 text-base font-medium text-white">
Enterprise Support & Services
</h3>
<p className="text-sm text-muted-foreground">
Custom solutions and dedicated support for your organization.
</p>
<ul className="mt-3 grid grid-cols-2 gap-y-1 text-sm text-slate-200">
{[
"SLA Guarantees / Priority Support",
"Aditional Security & Governance",
"Custom Solutions",
"Private Labeling",
].map((feature) => (
<li key={feature} className="flex text-muted-foreground">
<CheckIcon />
<span className="ml-2">{feature}</span>
</li>
))}
</ul>
<div className="mt-4 flex flex-col gap-2">
<Button
onClick={() => setOpenContactModal(true)}
className="w-full"
>
Get in touch
</Button>
</div>
</section>
</div>
</div>
</div>
</div>
</Container>
<ContactFormModal
open={openContactModal}
onOpenChange={setOpenContactModal}
defaultInquiryType="sales"
/>
</section>
);
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,76 @@
"use client";
import React from "react";
import { Check } from "lucide-react";
import { pricingFeatures, type FeatureValue } from "./pricing-data";
function FeatureCell({ value }: { value: FeatureValue }) {
if (value === true) {
return (
<div className="flex justify-center">
<Check className="h-5 w-5 text-primary" />
</div>
);
}
if (value === false) {
return <div className="text-center text-muted-foreground"></div>;
}
return (
<div className="text-center text-sm text-muted-foreground">{value}</div>
);
}
export function PricingFeatureTable() {
const grouped = pricingFeatures.reduce(
(acc, row) => {
if (!acc[row.category]) {
acc[row.category] = [];
}
acc[row.category].push(row);
return acc;
},
{} as Record<string, typeof pricingFeatures>,
);
return (
<div className="overflow-x-auto rounded-lg border border-border/50">
<div className="min-w-[600px]">
{/* Header */}
<div className="grid grid-cols-4 border-b border-border/50 bg-muted/10">
<div className="p-3 text-sm font-medium text-muted-foreground">
Features
</div>
<div className="p-3 text-center text-sm font-medium">Hobby</div>
<div className="p-3 text-center text-sm font-medium">Startup</div>
<div className="p-3 text-center text-sm font-medium">Enterprise</div>
</div>
{Object.entries(grouped).map(([category, rows]) => (
<React.Fragment key={category}>
<div className="border-b border-border/50 bg-muted/20 px-3 py-2">
<span className="text-sm font-semibold">{category}</span>
</div>
{rows.map((row) => (
<div
key={`${row.category}-${row.feature}`}
className="grid grid-cols-4 border-b border-border/30 items-center px-3 py-2 last:border-b-0 hover:bg-muted/5"
>
<div className="text-sm text-muted-foreground">
{row.feature}
</div>
<div className="py-2">
<FeatureCell value={row.hobby as FeatureValue} />
</div>
<div className="py-2">
<FeatureCell value={row.startup as FeatureValue} />
</div>
<div className="py-2">
<FeatureCell value={row.enterprise as FeatureValue} />
</div>
</div>
))}
</React.Fragment>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,100 @@
export type FeatureValue = true | false | string;
export interface FeatureRow {
category: string;
feature: string;
hobby: FeatureValue;
startup: FeatureValue;
enterprise: FeatureValue;
}
export const pricingFeatures: FeatureRow[] = [
// Application and Compose Deployment
{ category: "Application and Compose Deployment", feature: "Application Deployment", hobby: true, startup: true, enterprise: true },
{ category: "Application and Compose Deployment", feature: "Multiple Build Types", hobby: true, startup: true, enterprise: true },
{ category: "Application and Compose Deployment", feature: "Docker Compose Support (Stack & Compose)", hobby: true, startup: true, enterprise: true },
{ category: "Application and Compose Deployment", feature: "Git Integration", hobby: true, startup: true, enterprise: true },
{ category: "Application and Compose Deployment", feature: "Docker Registry Support", hobby: true, startup: true, enterprise: true },
{ category: "Application and Compose Deployment", feature: "Auto Deploy via Webhooks", hobby: true, startup: true, enterprise: true },
{ category: "Application and Compose Deployment", feature: "Preview Deployments", hobby: true, startup: true, enterprise: true },
{ category: "Application and Compose Deployment", feature: "One-Click Templates", hobby: true, startup: true, enterprise: true },
{ category: "Application and Compose Deployment", feature: "Watch Paths", hobby: true, startup: true, enterprise: true },
// Database Management
{ category: "Database Management", feature: "Supported Databases", hobby: true, startup: true, enterprise: true },
{ category: "Database Management", feature: "Database Management", hobby: true, startup: true, enterprise: true },
{ category: "Database Management", feature: "Terminal Access", hobby: true, startup: true, enterprise: true },
{ category: "Database Management", feature: "Database Backups", hobby: "1 backup per database", startup: "Unlimited Backups", enterprise: "Unlimited Backups" },
{ category: "Database Management", feature: "Backup Logs", hobby: true, startup: true, enterprise: true },
{ category: "Database Management", feature: "Custom Docker Images", hobby: true, startup: true, enterprise: true },
{ category: "Database Management", feature: "Resource Limits", hobby: true, startup: true, enterprise: true },
// Infrastructure and Scaling
{ category: "Infrastructure and Scaling", feature: "Multi-Server Support", hobby: true, startup: true, enterprise: true },
{ category: "Infrastructure and Scaling", feature: "Docker Swarm Clusters", hobby: true, startup: true, enterprise: true },
{ category: "Infrastructure and Scaling", feature: "Build Server Support", hobby: true, startup: true, enterprise: true },
{ category: "Infrastructure and Scaling", feature: "Traefik Integration", hobby: true, startup: true, enterprise: true },
{ category: "Infrastructure and Scaling", feature: "Custom Domain Management", hobby: true, startup: true, enterprise: true },
{ category: "Infrastructure and Scaling", feature: "Auto SSL/TLS", hobby: true, startup: true, enterprise: true },
{ category: "Infrastructure and Scaling", feature: "Zero Downtime Deployments", hobby: true, startup: true, enterprise: true },
{ category: "Infrastructure and Scaling", feature: "Registry-based Rollbacks", hobby: true, startup: true, enterprise: true },
{ category: "Infrastructure and Scaling", feature: "Resource Limits", hobby: true, startup: true, enterprise: true },
{ category: "Infrastructure and Scaling", feature: "Volume Management", hobby: true, startup: true, enterprise: true },
{ category: "Infrastructure and Scaling", feature: "Volume Backups", hobby: "1 backup per volume", startup: "Unlimited Backups", enterprise: "Unlimited Backups" },
// Monitoring and Logging
{ category: "Monitoring and Logging", feature: "Real-time Monitoring", hobby: true, startup: true, enterprise: true },
{ category: "Monitoring and Logging", feature: "Application Logs", hobby: true, startup: true, enterprise: true },
{ category: "Monitoring and Logging", feature: "Deployment Logs", hobby: true, startup: true, enterprise: true },
{ category: "Monitoring and Logging", feature: "Deployment History", hobby: true, startup: true, enterprise: true },
{ category: "Monitoring and Logging", feature: "Queue Management", hobby: true, startup: true, enterprise: true },
// Automation and Scheduling
{ category: "Automation and Scheduling", feature: "Scheduled Jobs", hobby: "1 job per server or container", startup: "Unlimited Jobs", enterprise: "Unlimited Jobs" },
{ category: "Automation and Scheduling", feature: "Docker Cleanup Jobs", hobby: true, startup: true, enterprise: true },
{ category: "Automation and Scheduling", feature: "Custom Scripts", hobby: true, startup: true, enterprise: true },
{ category: "Automation and Scheduling", feature: "CI/CD Integration", hobby: true, startup: true, enterprise: true },
{ category: "Automation and Scheduling", feature: "Run Commands", hobby: true, startup: true, enterprise: true },
// Notifications and Alerts
{ category: "Notifications and Alerts", feature: "Slack Notifications", hobby: true, startup: true, enterprise: true },
{ category: "Notifications and Alerts", feature: "Discord Notifications", hobby: true, startup: true, enterprise: true },
{ category: "Notifications and Alerts", feature: "Telegram Notifications", hobby: true, startup: true, enterprise: true },
{ category: "Notifications and Alerts", feature: "Email Notifications", hobby: true, startup: true, enterprise: true },
{ category: "Notifications and Alerts", feature: "Webhook Notifications", hobby: true, startup: true, enterprise: true },
{ category: "Notifications and Alerts", feature: "Lark Notifications", hobby: true, startup: true, enterprise: true },
{ category: "Notifications and Alerts", feature: "Gotify Integration", hobby: true, startup: true, enterprise: true },
{ category: "Notifications and Alerts", feature: "Ntfy Integration", hobby: true, startup: true, enterprise: true },
{ category: "Notifications and Alerts", feature: "Event Triggers", hobby: true, startup: true, enterprise: true },
// Security
{ category: "Security", feature: "Two-Factor Authentication", hobby: true, startup: true, enterprise: true },
{ category: "Security", feature: "API Key Authentication", hobby: true, startup: true, enterprise: true },
{ category: "Security", feature: "SSH Key Management", hobby: true, startup: true, enterprise: true },
{ category: "Security", feature: "Certificate Management", hobby: true, startup: true, enterprise: true },
{ category: "Security", feature: "Security Audits", hobby: true, startup: true, enterprise: true },
{ category: "Security", feature: "UFW Firewall Guidance", hobby: true, startup: true, enterprise: true },
// User and Organization Management
{ category: "User and Organization Management", feature: "User Management", hobby: "1 user", startup: "Unlimited", enterprise: "Unlimited" },
{ category: "User and Organization Management", feature: "Organizations", hobby: "1 Organizations", startup: "3 Organizations", enterprise: "10+ Organizations" },
{ category: "User and Organization Management", feature: "Projects", hobby: true, startup: true, enterprise: true },
{ category: "User and Organization Management", feature: "Environment", hobby: "2 Environments", startup: "Unlimited", enterprise: "Unlimited" },
{ category: "User and Organization Management", feature: "Role-based Permissions", hobby: "NA", startup: "Admin, Developer", enterprise: "Admin, Developer" },
{ category: "User and Organization Management", feature: "Social Login", hobby: "Google, Github, Gitlab", startup: "Google, Github, Gitlab", enterprise: "SSO (Okta, Azure, and more)" },
// Developer Tools
{ category: "Developer Tools", feature: "REST API", hobby: true, startup: true, enterprise: true },
{ category: "Developer Tools", feature: "CLI Tool", hobby: true, startup: true, enterprise: true },
{ category: "Developer Tools", feature: "Environment Variables", hobby: true, startup: true, enterprise: true },
{ category: "Developer Tools", feature: "Container Terminal", hobby: true, startup: true, enterprise: true },
{ category: "Developer Tools", feature: "Keyboard Shortcuts", hobby: true, startup: true, enterprise: true },
{ category: "Developer Tools", feature: "Swagger Documentation", hobby: true, startup: true, enterprise: true },
// Advanced Configuration
{ category: "Advanced Configuration", feature: "Swarm Settings", hobby: true, startup: true, enterprise: true },
{ category: "Advanced Configuration", feature: "Health Checks", hobby: true, startup: true, enterprise: true },
{ category: "Advanced Configuration", feature: "Network Configuration", hobby: true, startup: true, enterprise: true },
{ category: "Advanced Configuration", feature: "Container Labels", hobby: true, startup: true, enterprise: true },
{ category: "Advanced Configuration", feature: "Traefik File Editor", hobby: true, startup: true, enterprise: true },
{ category: "Advanced Configuration", feature: "Port Configuration", hobby: true, startup: true, enterprise: true },
{ category: "Advanced Configuration", feature: "Security Headers", hobby: true, startup: true, enterprise: true },
{ category: "Advanced Configuration", feature: "Redirects", hobby: true, startup: true, enterprise: true },
// Support and Services
{ category: "Support and Services", feature: "Community Support", hobby: true, startup: true, enterprise: true },
{ category: "Support and Services", feature: "Email and Chat Support", hobby: false, startup: true, enterprise: true },
{ category: "Support and Services", feature: "Phone & Video Support", hobby: false, startup: false, enterprise: true },
{ category: "Support and Services", feature: "SLA", hobby: "Online T&C's", startup: "Online T&C's", enterprise: true },
{ category: "Support and Services", feature: "MSA", hobby: "Online T&C's", startup: "Online T&C's", enterprise: true },
];

View File

@@ -1,3 +1,4 @@
"use client";
import { type VariantProps, cva } from "class-variance-authority";
import type * as React from "react";