feat: add pricing

This commit is contained in:
Mauricio Siu
2024-10-22 00:29:17 -06:00
parent 22e42b62ad
commit 431dadb6c2
7 changed files with 387 additions and 224 deletions

View File

@@ -1,143 +0,0 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import { format } from "date-fns";
import { ArrowRightIcon } from "lucide-react";
import { useState } from "react";
import { calculatePrice } from "./show-billing";
interface Props {
isAnnual: boolean;
serverQuantity: number;
}
export const ReviewPayment = ({ isAnnual, serverQuantity }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const { data: billingSubscription } =
api.stripe.getBillingSubscription.useQuery();
const { data: calculateUpgradeCost } =
api.stripe.calculateUpgradeCost.useQuery(
{
serverQuantity,
isAnnual,
},
{
enabled: !!serverQuantity && isOpen,
},
);
const { data: calculateNewMonthlyCost } =
api.stripe.calculateNewMonthlyCost.useQuery(
{
serverQuantity,
isAnnual,
},
{
enabled: !!serverQuantity && isOpen,
},
);
const isSameServersQty =
Number(billingSubscription?.totalServers) === serverQuantity;
const isSameCost =
Number(calculateNewMonthlyCost) ===
Number(billingSubscription?.monthlyAmount);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="outline">Review Payment</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Upgrade Plan</DialogTitle>
<DialogDescription>
You are about to upgrade your plan to a{" "}
{isAnnual ? "annual" : "monthly"} plan. This will automatically
renew your subscription.
</DialogDescription>
</DialogHeader>
<div className="flex flex-row w-full gap-4 items-center">
<div className="flex flex-col border gap-4 p-4 rounded-lg w-full">
<Label className="text-base font-semibold border-b border-b-divider pb-2">
Current Plan
</Label>
<div className="grid flex-1 gap-2">
<Label>Amount</Label>
<span className="text-sm text-muted-foreground">
${billingSubscription?.monthlyAmount}
</span>
</div>
<div className="grid flex-1 gap-2">
<Label>Servers</Label>
<span className="text-sm text-muted-foreground">
{billingSubscription?.totalServers}
</span>
</div>
<div className="grid flex-1 gap-2">
<Label>Next Payment</Label>
<span className="text-sm text-muted-foreground">
{billingSubscription?.nextPaymentDate
? format(billingSubscription?.nextPaymentDate, "MMM d, yyyy")
: "-"}
</span>
</div>
</div>
<div className="size-10">
<ArrowRightIcon className="size-6" />
</div>
<div className="flex flex-col border gap-4 p-4 rounded-lg w-full">
<Label className="text-base font-semibold border-b border-b-divider pb-2">
New Plan
</Label>
<div className="grid flex-1 gap-2">
<Label>Amount</Label>
<span className="text-sm text-muted-foreground">
${calculatePrice(serverQuantity).toFixed(2)}
</span>
</div>
<div className="grid flex-1 gap-2">
<Label>Servers</Label>
<span className="text-sm text-muted-foreground">
{serverQuantity}
</span>
</div>
<div className="grid flex-1 gap-2">
<Label>Difference</Label>
<span className="text-sm text-muted-foreground">
{isSameServersQty ? "-" : `$${calculateUpgradeCost} USD`}{" "}
</span>
</div>
<div className="grid flex-1 gap-2">
<Label>New {isAnnual ? "annual" : "monthly"} cost</Label>
<span className="text-sm text-muted-foreground">
{isSameCost ? "-" : `$${calculateNewMonthlyCost} USD`}{" "}
</span>
</div>
</div>
</div>
<DialogFooter className="sm:justify-end">
<DialogClose asChild>
<Button type="button" variant="secondary">
Pay
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -181,7 +181,7 @@ export const ShowBilling = () => {
<NumberInput
value={serverQuantity}
onChange={(e) => {
setServerQuantity(e.target.value);
setServerQuantity(e.target.value as unknown as number);
}}
/>

View File

@@ -1,13 +1,17 @@
"use client";
import clsx from "clsx";
import { cn } from "@/lib/utils";
import { MinusIcon, PlusIcon } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Container } from "./Container";
import { trackGAEvent } from "./analitycs";
import { Button } from "./ui/button";
import { Switch } from "./ui/switch";
import { Badge } from "./ui/badge";
import { Button, buttonVariants } from "./ui/button";
import { NumberInput } from "./ui/input";
import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
function SwirlyDoodle(props: React.ComponentPropsWithoutRef<"svg">) {
return (
@@ -55,7 +59,14 @@ function CheckIcon({
</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;
};
function Plan({
name,
price,
@@ -63,6 +74,7 @@ function Plan({
href,
features,
featured = false,
buttonText = "Get Started",
}: {
name: string;
price: string;
@@ -70,6 +82,7 @@ function Plan({
href: string;
features: Array<string>;
featured?: boolean;
buttonText?: string;
}) {
const router = useRouter();
return (
@@ -116,23 +129,17 @@ function Plan({
}}
className="rounded-full mt-8"
>
Get started
{buttonText}
</Button>
{/* <Button
href={href}
variant={featured ? "solid" : "outline"}
color="white"
className="mt-8"
aria-label={`Get started with the ${name} plan for ${price}`}
>
Get started
</Button> */}
</section>
);
}
export function Pricing() {
const [monthly, setMonthly] = useState(false);
const router = useRouter();
const [isAnnual, setIsAnnual] = useState(true);
const [serverQuantity, setServerQuantity] = useState(3);
const featured = true;
return (
<section
id="pricing"
@@ -152,54 +159,200 @@ export function Pricing() {
Deploy Smarter, Scale Faster Without Breaking the Bank
</p>
</div>
<div className="mt-10 flex flex-row gap-x-4 justify-center">
<Switch checked={monthly} onCheckedChange={(e) => setMonthly(e)} />
{!monthly ? "Monthly" : "Yearly"}
</div>
<div className=" mt-10 mx-auto">
<div className="mt-16 grid md:grid-cols-2 gap-y-10 mx-auto w-full lg:-mx-8 xl:mx-0 xl:gap-x-8">
<Plan
name="Free"
price="$0"
description="Perfect for developers who prefer to manage their own servers."
href="https://docs.dokploy.com/en/docs/core/get-started/installation#docker"
features={[
"Unlimited deployments",
"Self-hosted on your own infrastructure",
"Full access to all deployment features",
"Docker Swarm and Docker Compose support",
"Community support",
"Custom domains and SSL integration",
"No feature limitations on the core platform",
]}
/>
<Plan
featured
name="General"
price={!monthly ? "$5.99" : "$4.49"}
description="Ideal for indie hackers, freelancers, agencies, and businesses looking for a managed solution."
href="/register"
features={[
"2 free server included (user-provided)",
"All self-hosted features without hosting the UI",
"Dokploy infrastructure managed by us",
"$3.50 per additional server (user-provided)",
]}
/>
{/* <Plan
name="Enterprise"
price="$39"
description="For even the biggest enterprise companies."
href="/register"
features={[
"Send unlimited quotes and invoices",
"Connect up to 15 bank accounts",
"Track up to 200 expenses per month",
"Automated payroll support",
"Export up to 25 reports, including TPS",
]}
/> */}
<div className="mt-16 flex flex-col gap-10 mx-auto w-full lg:-mx-8 xl:mx-0 xl:gap-x-8 justify-center items-center">
<Tabs
defaultValue="monthly"
value={isAnnual ? "annual" : "monthly"}
// className="w-full"
onValueChange={(e) => setIsAnnual(e === "annual")}
>
<TabsList>
<TabsTrigger value="monthly">Monthly</TabsTrigger>
<TabsTrigger value="annual">Annual</TabsTrigger>
</TabsList>
</Tabs>
<div className="flex flex-row max-w-4xl gap-4 mx-auto">
<section
className={clsx(
"flex flex-col rounded-3xl border-dashed border-muted border-2 px-4 max-w-sm",
featured
? "order-first bg-black border py-8 lg:order-none"
: "lg:py-8",
)}
>
<div className="flex flex-row gap-2 items-center">
<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 font-medium text-lg text-white">
Dokploy Open Source
</h3>
<p
className={clsx(
"text-sm",
featured ? "text-white" : "text-slate-400",
)}
>
Manager your own infrastructure installing dokploy ui in 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",
"Unlimited Deployments",
"Self-hosted Infrastructure",
"Community Support",
"Access to Core Features",
"Dokploy Integration",
"Basic Backups",
"Access to All Updates",
].map((feature) => (
<li key={feature} className="flex text-muted-foreground">
<CheckIcon />
<span className="ml-2">{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">
Unlimited Servers
</span>
</div>
</div>
</section>
<section
className={clsx(
"flex flex-col rounded-3xl border-dashed border-2 px-4 max-w-sm",
featured
? "order-first bg-black border py-8 lg:order-none"
: "lg:py-8",
)}
>
<div className="flex flex-row gap-2 items-center mb-4">
<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>
) : (
<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-white">
Dokploy Plan
</h3>
<p
className={clsx(
"text-sm",
featured ? "text-white" : "text-slate-400",
)}
>
No need to manage 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",
"Priority Support",
"Future-Proof Features",
].map((feature) => (
<li key={feature} className="flex text-muted-foreground">
<CheckIcon />
<span className="ml-2">{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 (You bring the 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(
"justify-between",
// : "justify-end",
"flex flex-row items-center gap-2 mt-4",
)}
>
<div className="justify-end w-full">
<Link
href="https://app.dokploy.com/register"
target="_blank"
className={buttonVariants({ className: "w-full" })}
>
Subscribe
</Link>
</div>
</div>
</div>
</section>
</div>
</div>
</div>
</Container>
@@ -212,48 +365,43 @@ export function Pricing() {
const faqs = [
[
{
question: "How does Dokploy's free plan work?",
question: "How does Dokploy's Open Source plan work?",
answer:
"The free plan allows you to self-host Dokploy on your own infrastructure with unlimited deployments and full access to all features.",
"You can host Dokploy UI on your own infrastructure and you will be responsible for the maintenance and updates.",
},
{
question: "Do I need to provide my own server for the managed plan?",
answer:
"Yes, in the managed plan, you provide your own server, and we manage the Dokploy UI infrastructure for you.",
"Yes, in the managed plan, you provide your own server eg(Hetzner, Hostinger, AWS, ETC.) VPS, and we manage the Dokploy UI infrastructure for you.",
},
{
question: "What happens if I need more than one server?",
answer:
"Each additional server costs $3.99/month and can be easily added to your account.",
"The first server costs $4.50/month, if you buy more than one it will be $3.50/month per server.",
},
],
[
{
question: "Can I use my custom domain with Dokploy?",
answer:
"Yes, custom domain support is available on all plans, including the free version.",
},
{
question: "Is there a limit on the number of deployments?",
answer:
"No, there is no limit on the number of deployments in any of the plans.",
},
{
question: "Do I have to manually configure Traefik?",
question: "What happens if I exceed my purchased server limit?",
answer:
"Dokploy offers dynamic Traefik configuration out-of-the-box, so no manual setup is needed.",
"The most recently added servers will be deactivated. You won't be able to create services on inactive servers until they are reactivated.",
},
{
question: "Do you offer a refunds?",
answer:
"We do not offer refunds. However, you can cancel your subscription at any time. Feel free to try our open-source version for free before making a purchase.",
},
],
[
{
question: "How do automated backups work?",
answer:
"Automated backups are included in the managed plan and are limited to database backups only.",
},
{
question: "What kind of support do you offer?",
answer:
"We offer community support for the free plan and priority support for paid plans.",
"We offer community support for the open source version and priority support for paid plans.",
},
{
question: "Is Dokploy open-source?",
@@ -279,7 +427,7 @@ export function Faqs() {
{"Frequently asked questions"}
</h2>
<p className="mt-4 text-lg tracking-tight text-muted-foreground">
If you cant find what youre looking for, please submit an issue
If you cant find what youre looking for, please send us an email
to:{" "}
<Link href={"mailto:support@dokploy.com"} className="text-primary">
support@dokploy.com

View File

@@ -0,0 +1,36 @@
import { type VariantProps, cva } from "class-variance-authority";
import type * as React from "react";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };

View File

@@ -0,0 +1,69 @@
import { cn } from "@/lib/utils";
import * as React from "react";
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {
errorMessage?: string;
}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, errorMessage, type, ...props }, ref) => {
return (
<>
<input
type={type}
className={cn(
// bg-gray
"flex h-10 w-full rounded-md bg-input px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
{errorMessage && (
<span className="text-sm text-red-600 text-secondary-foreground">
{errorMessage}
</span>
)}
</>
);
},
);
Input.displayName = "Input";
const NumberInput = React.forwardRef<HTMLInputElement, InputProps>(
({ className, errorMessage, ...props }, ref) => {
return (
<Input
type="text"
className={cn("text-left", className)}
ref={ref}
{...props}
value={props.value === undefined ? undefined : String(props.value)}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
props.onChange?.(e);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
const syntheticEvent = {
...e,
target: {
...e.target,
value: number,
},
};
props.onChange?.(
syntheticEvent as unknown as React.ChangeEvent<HTMLInputElement>,
);
}
}
}}
/>
);
},
);
NumberInput.displayName = "NumberInput";
export { Input, NumberInput };

View File

@@ -0,0 +1,53 @@
import * as TabsPrimitive from "@radix-ui/react-tabs";
import * as React from "react";
import { cn } from "@/lib/utils";
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className,
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className,
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className,
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@@ -24,9 +24,9 @@
"projectsDes": "Manage and organize all your projects in one place, keeping detailed track of progress and resource allocation.",
"applications": "Applications & Databases",
"applicationsDes": "Centralize control over your applications and databases for enhanced security and efficiency, simplifying access and management across your infrastructure.",
"compose": "compose",
"compose": "Compose",
"composeDes": "Native Docker Compose support for manage complex applications and services with ease.",
"multinode": "multinode",
"multinode": "Multinode",
"multinodeDes": "Scale applications to multiples nodes using docker swarm to manage the cluster.",
"monitoring": "Monitoring",
"monitoringDes": "Monitor your systems' performance and health in real time, ensuring continuous and uninterrupted operation.",