mirror of
https://github.com/Dokploy/dokploy
synced 2025-06-26 18:27:59 +00:00
feat: add pricing
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -181,7 +181,7 @@ export const ShowBilling = () => {
|
||||
<NumberInput
|
||||
value={serverQuantity}
|
||||
onChange={(e) => {
|
||||
setServerQuantity(e.target.value);
|
||||
setServerQuantity(e.target.value as unknown as number);
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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 can’t find what you’re looking for, please submit an issue
|
||||
If you can’t find what you’re looking for, please send us an email
|
||||
to:{" "}
|
||||
<Link href={"mailto:support@dokploy.com"} className="text-primary">
|
||||
support@dokploy.com
|
||||
|
||||
36
apps/website/components/ui/badge.tsx
Normal file
36
apps/website/components/ui/badge.tsx
Normal 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 };
|
||||
69
apps/website/components/ui/input.tsx
Normal file
69
apps/website/components/ui/input.tsx
Normal 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 };
|
||||
53
apps/website/components/ui/tabs.tsx
Normal file
53
apps/website/components/ui/tabs.tsx
Normal 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 };
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user