[WEB-3854] feat: billing and plans new design (#6920)

* [WEB-3854] feat: billing and plans new design

* chore: add missing styles
This commit is contained in:
Prateek Shourya 2025-04-11 20:37:25 +05:30 committed by GitHub
parent ed8d00acb1
commit 06be9ab81b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 3084 additions and 331 deletions

View file

@ -0,0 +1,163 @@
import { forwardRef } from "react";
import { observer } from "mobx-react";
import { ArrowDown, ArrowUp } from "lucide-react";
// plane imports
import { Button } from "@plane/ui";
import { cn } from "@plane/utils";
// constants
import { ComingSoonBadge, PLANE_PLANS, PLANS_LIST, TPlanePlans } from "@/constants/plans";
// local imports
import { PlanFeatureDetail } from "./feature-detail";
type TPlansComparisonBaseProps = {
planeDetails: React.ReactNode;
isSelfManaged: boolean;
isScrolled: boolean;
isCompareAllFeaturesSectionOpen: boolean;
setIsCompareAllFeaturesSectionOpen: React.Dispatch<React.SetStateAction<boolean>>;
setIsScrolled: React.Dispatch<React.SetStateAction<boolean>>;
};
export const shouldRenderPlanDetail = (planKey: TPlanePlans) => {
// Free plan is not required to be shown in the comparison
if (planKey === "free") return false;
// Plane one plan is not longer available
if (planKey === "one") return false;
return true;
};
export const PlansComparisonBase = observer(
forwardRef<HTMLDivElement, TPlansComparisonBaseProps>(function PlansComparisonBase(
props: TPlansComparisonBaseProps,
ref: React.Ref<HTMLDivElement>
) {
const {
planeDetails,
isSelfManaged,
isScrolled,
isCompareAllFeaturesSectionOpen,
setIsCompareAllFeaturesSectionOpen,
setIsScrolled,
} = props;
// plan details
const { planDetails, planHighlights, planComparison } = PLANE_PLANS;
const numberOfPlansToRender = Object.keys(planDetails).filter((planKey) =>
shouldRenderPlanDetail(planKey as TPlanePlans)
).length;
const getSubscriptionType = (planKey: TPlanePlans) => planDetails[planKey].id;
return (
<div
ref={ref}
className={`size-full px-2 overflow-x-auto horizontal-scrollbar scrollbar-sm transition-all duration-500 ease-out will-change-transform`}
>
<div className="max-w-full" style={{ minWidth: `${numberOfPlansToRender * 280}px` }}>
<div className="h-full flex flex-col gap-y-10">
<div
className={cn(
"flex-shrink-0 sticky top-2 z-10 bg-custom-background-100 grid gap-3 text-sm font-medium even:bg-custom-background-90 transition-all duration-500 ease-out will-change-transform",
{
"border-b border-custom-border-200 mt-2": isScrolled,
}
)}
style={{
gridTemplateColumns: `repeat(${numberOfPlansToRender + 1}, minmax(0, 1fr))`,
transform: isScrolled ? "translateY(-8px)" : "translateY(0)",
}}
>
<div className="col-span-1 p-3 space-y-0.5 text-base font-medium" />
{planeDetails}
</div>
{/* Plan Headers */}
<section className="flex-shrink-0">
{/* Plan Highlights */}
<div
className="grid gap-3 py-1 text-sm text-custom-text-200 even:bg-custom-background-90 rounded-sm"
style={{ gridTemplateColumns: `repeat(${numberOfPlansToRender + 1}, minmax(0, 1fr))` }}
>
<div className="col-span-1 p-3 text-base font-medium">Highlights</div>
{Object.entries(planHighlights).map(
([planKey, highlights]) =>
shouldRenderPlanDetail(planKey as TPlanePlans) && (
<div key={planKey} className="col-span-1 p-3">
<ul className="list-disc space-y-1">
{highlights.map((highlight, index) => (
<li key={index}>{highlight}</li>
))}
</ul>
</div>
)
)}
</div>
</section>
{/* Feature Comparison */}
{isCompareAllFeaturesSectionOpen && (
<>
{planComparison.map((section, sectionIdx) => (
<section key={sectionIdx} className="flex-shrink-0">
<h2 className="flex gap-2 items-start text-lg font-semibold text-custom-text-300 mb-2 pl-2">
{section.title} {section.comingSoon && <ComingSoonBadge />}
</h2>
<div className="border-t border-custom-border-200">
{section.features.map((feature, featureIdx) => (
<div
key={featureIdx}
className="grid gap-3 text-sm text-custom-text-200 even:bg-custom-background-90 rounded-sm"
style={{ gridTemplateColumns: `repeat(${numberOfPlansToRender + 1}, minmax(0, 1fr))` }}
>
<div className="col-span-1 p-3 flex items-center text-base font-medium">
<div className="w-full flex gap-2 items-start justify-between">
{feature.title} {feature.comingSoon && <ComingSoonBadge />}
</div>
</div>
{PLANS_LIST.map(
(planKey) =>
shouldRenderPlanDetail(planKey) && (
<div
key={planKey}
className="col-span-1 p-3 flex items-center justify-center text-center"
>
<PlanFeatureDetail
subscriptionType={getSubscriptionType(planKey)}
data={
isSelfManaged
? (feature["self-hosted"]?.[planKey] ?? feature.cloud[planKey])
: feature.cloud[planKey]
}
/>
</div>
)
)}
</div>
))}
</div>
</section>
))}
</>
)}
</div>
{/* Toggle Button */}
<div className="flex items-center justify-center gap-1 my-4 pb-2">
<Button
variant="link-neutral"
onClick={() => {
const newValue = !isCompareAllFeaturesSectionOpen;
setIsCompareAllFeaturesSectionOpen(newValue);
if (newValue) {
setIsScrolled(true);
}
}}
className="hover:bg-custom-background-90"
>
{isCompareAllFeaturesSectionOpen ? "Collapse comparison" : "Compare all features"}
{isCompareAllFeaturesSectionOpen ? <ArrowUp className="size-4" /> : <ArrowDown className="size-4" />}
</Button>
</div>
</div>
</div>
);
})
);

View file

@ -0,0 +1,28 @@
import { FC } from "react";
import { CheckCircle2, Minus, MinusCircle } from "lucide-react";
// plane imports
import { EProductSubscriptionEnum } from "@plane/constants";
import { cn } from "@plane/utils";
// constants
import { getSubscriptionTextColor } from "@/components/workspace/billing/subscription";
import { TPlanFeatureData } from "@/constants/plans";
type TPlanFeatureDetailProps = {
subscriptionType: EProductSubscriptionEnum;
data: TPlanFeatureData;
};
export const PlanFeatureDetail: FC<TPlanFeatureDetailProps> = (props) => {
const { subscriptionType, data } = props;
if (data === null || data === undefined) {
return <Minus className="size-4 text-custom-text-400" />;
}
if (data === true) {
return <CheckCircle2 className={cn(getSubscriptionTextColor(subscriptionType), "size-4")} />;
}
if (data === false) {
return <MinusCircle className="size-4 text-custom-text-400" />;
}
return <>{data}</>;
};

View file

@ -0,0 +1,2 @@
export * from "./base";
export * from "./feature-detail";

View file

@ -0,0 +1 @@
export * from "./comparison";

View file

@ -0,0 +1,213 @@
// plane imports
import { EProductSubscriptionEnum } from "@plane/constants";
import { cn } from "@plane/utils";
// --------------- NOTE ----------------
// This has to be in web application as tailwind won't be able to resolve the colors
// ------------------------------------
export const getSubscriptionTextColor = (
planVariant: EProductSubscriptionEnum,
shade: "200" | "400" = "200"
): string => {
const subscriptionColors = {
[EProductSubscriptionEnum.ONE]: {
"200": "text-custom-subscription-one-200",
"400": "text-custom-subscription-one-400",
},
[EProductSubscriptionEnum.PRO]: {
"200": "text-custom-subscription-pro-200",
"400": "text-custom-subscription-pro-400",
},
[EProductSubscriptionEnum.BUSINESS]: {
"200": "text-custom-subscription-business-200",
"400": "text-custom-subscription-business-400",
},
[EProductSubscriptionEnum.ENTERPRISE]: {
"200": "text-custom-subscription-enterprise-200",
"400": "text-custom-subscription-enterprise-400",
},
[EProductSubscriptionEnum.FREE]: {
"200": "text-custom-subscription-free-200",
"400": "text-custom-subscription-free-400",
},
};
return subscriptionColors[planVariant]?.[shade] ?? subscriptionColors[EProductSubscriptionEnum.FREE][shade];
};
export const getSubscriptionBackgroundColor = (
planVariant: EProductSubscriptionEnum,
shade: "50" | "100" | "200" | "400" = "100"
): string => {
const subscriptionColors = {
[EProductSubscriptionEnum.ONE]: {
"50": "bg-custom-subscription-one-200/10",
"100": "bg-custom-subscription-one-200/20",
"200": "bg-custom-subscription-one-200",
"400": "bg-custom-subscription-one-400",
},
[EProductSubscriptionEnum.PRO]: {
"50": "bg-custom-subscription-pro-200/10",
"100": "bg-custom-subscription-pro-200/20",
"200": "bg-custom-subscription-pro-200",
"400": "bg-custom-subscription-pro-400",
},
[EProductSubscriptionEnum.BUSINESS]: {
"50": "bg-custom-subscription-business-200/10",
"100": "bg-custom-subscription-business-200/20",
"200": "bg-custom-subscription-business-200",
"400": "bg-custom-subscription-business-400",
},
[EProductSubscriptionEnum.ENTERPRISE]: {
"50": "bg-custom-subscription-enterprise-200/10",
"100": "bg-custom-subscription-enterprise-200/20",
"200": "bg-custom-subscription-enterprise-200",
"400": "bg-custom-subscription-enterprise-400",
},
[EProductSubscriptionEnum.FREE]: {
"50": "bg-custom-subscription-free-200/10",
"100": "bg-custom-subscription-free-200/20",
"200": "bg-custom-subscription-free-200",
"400": "bg-custom-subscription-free-400",
},
};
return subscriptionColors[planVariant]?.[shade] ?? subscriptionColors[EProductSubscriptionEnum.FREE][shade];
};
export const getSubscriptionBorderColor = (
planVariant: EProductSubscriptionEnum,
shade: "200" | "400" = "200"
): string => {
const subscriptionColors = {
[EProductSubscriptionEnum.ONE]: {
"200": "border-custom-subscription-one-200",
"400": "border-custom-subscription-one-400",
},
[EProductSubscriptionEnum.PRO]: {
"200": "border-custom-subscription-pro-200",
"400": "border-custom-subscription-pro-400",
},
[EProductSubscriptionEnum.BUSINESS]: {
"200": "border-custom-subscription-business-200",
"400": "border-custom-subscription-business-400",
},
[EProductSubscriptionEnum.ENTERPRISE]: {
"200": "border-custom-subscription-enterprise-200",
"400": "border-custom-subscription-enterprise-400",
},
[EProductSubscriptionEnum.FREE]: {
"200": "border-custom-subscription-free-200",
"400": "border-custom-subscription-free-400",
},
default: "border-custom-subscription-free-400",
};
return subscriptionColors[planVariant]?.[shade] ?? subscriptionColors.default;
};
export const getUpgradeButtonStyle = (
planVariant: EProductSubscriptionEnum,
isDisabled: boolean
): string | undefined => {
const baseClassNames = "border bg-custom-background-100";
const hoverClassNames = !isDisabled ? "hover:text-white hover:bg-gradient-to-br" : "";
const disabledClassNames = isDisabled ? "opacity-70 cursor-not-allowed" : "";
const COMMON_CLASSNAME = cn(baseClassNames, hoverClassNames, disabledClassNames);
switch (planVariant) {
case EProductSubscriptionEnum.ENTERPRISE:
return cn(
"text-custom-subscription-enterprise-200 from-custom-subscription-enterprise-200 to-custom-subscription-enterprise-400",
getSubscriptionBorderColor(planVariant, "200"),
COMMON_CLASSNAME
);
case EProductSubscriptionEnum.BUSINESS:
return cn(
"text-custom-subscription-business-200 from-custom-subscription-business-200 to-custom-subscription-business-400",
getSubscriptionBorderColor(planVariant, "200"),
COMMON_CLASSNAME
);
case EProductSubscriptionEnum.PRO:
return cn(
"text-custom-subscription-pro-200 from-custom-subscription-pro-200 to-custom-subscription-pro-400",
getSubscriptionBorderColor(planVariant, "200"),
COMMON_CLASSNAME
);
case EProductSubscriptionEnum.ONE:
return cn(
"text-custom-subscription-one-200 from-custom-subscription-one-200 to-custom-subscription-one-400",
getSubscriptionBorderColor(planVariant, "200"),
COMMON_CLASSNAME
);
case EProductSubscriptionEnum.FREE:
default:
return cn(
"text-custom-subscription-free-200 from-custom-subscription-free-200 to-custom-subscription-free-400",
getSubscriptionBorderColor(planVariant, "200"),
COMMON_CLASSNAME
);
}
};
export const getUpgradeCardVariantStyle = (planVariant: EProductSubscriptionEnum): string | undefined => {
const COMMON_CLASSNAME = cn("bg-gradient-to-b from-0% to-40% border border-custom-border-200 rounded-xl");
switch (planVariant) {
case EProductSubscriptionEnum.ENTERPRISE:
return cn("from-custom-subscription-enterprise-200/[0.14] to-transparent", COMMON_CLASSNAME);
case EProductSubscriptionEnum.BUSINESS:
return cn("from-custom-subscription-business-200/[0.14] to-transparent", COMMON_CLASSNAME);
case EProductSubscriptionEnum.PRO:
return cn("from-custom-subscription-pro-200/[0.14] to-transparent", COMMON_CLASSNAME);
case EProductSubscriptionEnum.ONE:
return cn("from-custom-subscription-one-200/[0.14] to-transparent", COMMON_CLASSNAME);
case EProductSubscriptionEnum.FREE:
default:
return cn("from-custom-subscription-free-200/[0.14] to-transparent", COMMON_CLASSNAME);
}
};
export const getSuccessModalVariantStyle = (planVariant: EProductSubscriptionEnum) => {
const COMMON_CLASSNAME = cn("bg-gradient-to-b from-0% to-30% rounded-2xl");
switch (planVariant) {
case EProductSubscriptionEnum.ENTERPRISE:
return cn("from-custom-subscription-enterprise-200/[0.14] to-transparent", COMMON_CLASSNAME);
case EProductSubscriptionEnum.BUSINESS:
return cn("from-custom-subscription-business-200/[0.14] to-transparent", COMMON_CLASSNAME);
case EProductSubscriptionEnum.PRO:
return cn("from-custom-subscription-pro-200/[0.14] to-transparent", COMMON_CLASSNAME);
case EProductSubscriptionEnum.ONE:
return cn("from-custom-subscription-one-200/[0.14] to-transparent", COMMON_CLASSNAME);
case EProductSubscriptionEnum.FREE:
default:
return cn("from-custom-subscription-free-200/[0.14] to-transparent", COMMON_CLASSNAME);
}
};
export const getBillingAndPlansCardVariantStyle = (planVariant: EProductSubscriptionEnum) => {
const COMMON_CLASSNAME = cn("bg-gradient-to-b from-0% to-50%");
switch (planVariant) {
case EProductSubscriptionEnum.ENTERPRISE:
return cn("from-custom-subscription-enterprise-200/[0.14] to-transparent", COMMON_CLASSNAME);
case EProductSubscriptionEnum.BUSINESS:
return cn("from-custom-subscription-business-200/[0.14] to-transparent", COMMON_CLASSNAME);
case EProductSubscriptionEnum.PRO:
return cn("from-custom-subscription-pro-200/[0.14] to-transparent", COMMON_CLASSNAME);
case EProductSubscriptionEnum.ONE:
return cn("from-custom-subscription-one-200/[0.14] to-transparent", COMMON_CLASSNAME);
case EProductSubscriptionEnum.FREE:
default:
return cn("from-custom-subscription-free-200/[0.14] to-transparent", COMMON_CLASSNAME);
}
};
export const getSubscriptionTextAndBackgroundColor = (planVariant: EProductSubscriptionEnum) =>
cn(getSubscriptionTextColor(planVariant), getSubscriptionBackgroundColor(planVariant));
export const getDiscountPillStyle = (planVariant: EProductSubscriptionEnum): string =>
cn(getSubscriptionBackgroundColor(planVariant, "200"), "text-white");

View file

@ -5,3 +5,4 @@ export * from "./confirm-workspace-member-remove";
export * from "./create-workspace-form";
export * from "./logo";
export * from "./invite-modal";
export * from "./billing";