[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:
parent
ed8d00acb1
commit
06be9ab81b
36 changed files with 3084 additions and 331 deletions
1
web/core/components/license/index.ts
Normal file
1
web/core/components/license/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./modal";
|
||||
105
web/core/components/license/modal/card/base-paid-plan-card.tsx
Normal file
105
web/core/components/license/modal/card/base-paid-plan-card.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
"use client";
|
||||
|
||||
import { FC, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { CheckCircle } from "lucide-react";
|
||||
import { Tab } from "@headlessui/react";
|
||||
// plane imports
|
||||
import { EProductSubscriptionEnum } from "@plane/constants";
|
||||
// helpers
|
||||
import { TBillingFrequency, TSubscriptionPrice } from "@plane/types";
|
||||
import { cn, getBaseSubscriptionName, getSubscriptionName } from "@plane/utils";
|
||||
// components
|
||||
import {
|
||||
getSubscriptionBackgroundColor,
|
||||
getUpgradeCardVariantStyle,
|
||||
} from "@/components/workspace/billing/subscription";
|
||||
|
||||
export type TBasePaidPlanCardProps = {
|
||||
planVariant: EProductSubscriptionEnum;
|
||||
features: string[];
|
||||
prices: TSubscriptionPrice[];
|
||||
upgradeLoaderType: Omit<EProductSubscriptionEnum, "FREE"> | undefined;
|
||||
verticalFeatureList?: boolean;
|
||||
extraFeatures?: string | React.ReactNode;
|
||||
renderPriceContent: (price: TSubscriptionPrice) => React.ReactNode;
|
||||
renderActionButton: (price: TSubscriptionPrice) => React.ReactNode;
|
||||
isSelfHosted: boolean;
|
||||
};
|
||||
|
||||
export const BasePaidPlanCard: FC<TBasePaidPlanCardProps> = observer((props) => {
|
||||
const {
|
||||
planVariant,
|
||||
features,
|
||||
prices,
|
||||
verticalFeatureList = false,
|
||||
extraFeatures,
|
||||
renderPriceContent,
|
||||
renderActionButton,
|
||||
isSelfHosted,
|
||||
} = props;
|
||||
// states
|
||||
const [selectedPlan, setSelectedPlan] = useState<TBillingFrequency>("month");
|
||||
const basePlan = getBaseSubscriptionName(planVariant, isSelfHosted);
|
||||
const upgradeCardVariantStyle = getUpgradeCardVariantStyle(planVariant);
|
||||
// Plane details
|
||||
const planeName = getSubscriptionName(planVariant);
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col py-6 px-3", upgradeCardVariantStyle)}>
|
||||
<Tab.Group selectedIndex={selectedPlan === "month" ? 0 : 1}>
|
||||
<div className="flex w-full justify-center h-9">
|
||||
<Tab.List
|
||||
className={cn("flex space-x-1 rounded-md p-0.5 w-60", getSubscriptionBackgroundColor(planVariant, "50"))}
|
||||
>
|
||||
{prices.map((price: TSubscriptionPrice) => (
|
||||
<Tab
|
||||
key={price.key}
|
||||
className={({ selected }) =>
|
||||
cn(
|
||||
"w-full rounded py-1 text-sm font-medium leading-5",
|
||||
selected
|
||||
? "bg-custom-background-100 text-custom-text-100 shadow"
|
||||
: "text-custom-text-300 hover:text-custom-text-200"
|
||||
)
|
||||
}
|
||||
onClick={() => setSelectedPlan(price.recurring)}
|
||||
>
|
||||
{renderPriceContent(price)}
|
||||
</Tab>
|
||||
))}
|
||||
</Tab.List>
|
||||
</div>
|
||||
<Tab.Panels>
|
||||
{prices.map((price: TSubscriptionPrice) => (
|
||||
<Tab.Panel key={price.key}>
|
||||
<div className="pt-6 text-center">
|
||||
<div className="text-xl font-medium">Plane {planeName}</div>
|
||||
{renderActionButton(price)}
|
||||
</div>
|
||||
<div className="px-2 pt-6 pb-2">
|
||||
<div className="p-2 text-sm font-semibold">{`Everything in ${basePlan} +`}</div>
|
||||
<ul className="grid grid-cols-12 gap-x-4">
|
||||
{features.map((feature) => (
|
||||
<li
|
||||
key={feature}
|
||||
className={cn("col-span-12 relative rounded-md p-2 flex", {
|
||||
"sm:col-span-6": !verticalFeatureList,
|
||||
})}
|
||||
>
|
||||
<p className="w-full text-sm font-medium leading-5 flex items-center line-clamp-1">
|
||||
<CheckCircle className="h-4 w-4 mr-2 text-custom-text-300 flex-shrink-0" />
|
||||
<span className="text-custom-text-200 truncate">{feature}</span>
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{extraFeatures && <div>{extraFeatures}</div>}
|
||||
</div>
|
||||
</Tab.Panel>
|
||||
))}
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
105
web/core/components/license/modal/card/checkout-button.tsx
Normal file
105
web/core/components/license/modal/card/checkout-button.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
"use client";
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { EProductSubscriptionEnum } from "@plane/constants";
|
||||
import { IPaymentProduct, TSubscriptionPrice } from "@plane/types";
|
||||
import { getButtonStyling, Loader } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// helpers
|
||||
import { getUpgradeButtonStyle } from "@/components/workspace/billing/subscription";
|
||||
|
||||
export type TCheckoutParams = {
|
||||
planVariant: EProductSubscriptionEnum;
|
||||
productId: string;
|
||||
priceId: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
planeName: string;
|
||||
planVariant: EProductSubscriptionEnum;
|
||||
isLoading?: boolean;
|
||||
product: IPaymentProduct | undefined;
|
||||
price: TSubscriptionPrice;
|
||||
upgradeCTA?: string;
|
||||
upgradeLoaderType: Omit<EProductSubscriptionEnum, "FREE"> | undefined;
|
||||
renderTrialButton?: (props: { productId: string | undefined; priceId: string | undefined }) => React.ReactNode;
|
||||
handleCheckout: (params: TCheckoutParams) => void;
|
||||
isSelfHosted: boolean;
|
||||
isTrialAllowed: boolean;
|
||||
};
|
||||
|
||||
export const PlanCheckoutButton: FC<Props> = observer((props) => {
|
||||
const {
|
||||
planeName,
|
||||
planVariant,
|
||||
isLoading,
|
||||
product,
|
||||
price,
|
||||
upgradeCTA,
|
||||
upgradeLoaderType,
|
||||
renderTrialButton,
|
||||
handleCheckout,
|
||||
isSelfHosted,
|
||||
isTrialAllowed,
|
||||
} = props;
|
||||
const upgradeButtonStyle =
|
||||
getUpgradeButtonStyle(planVariant, !!upgradeLoaderType) ?? getButtonStyling("primary", "lg", !!upgradeLoaderType);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="pb-4 text-center transition-all duration-700 animate-slide-up">
|
||||
<div className="text-2xl font-semibold h-9 transition-all duration-300">
|
||||
{isLoading ? (
|
||||
<Loader className="flex flex-col items-center justify-center">
|
||||
<Loader.Item height="36px" width="4rem" />
|
||||
</Loader>
|
||||
) : (
|
||||
<span className="animate-fade-in">
|
||||
{price.currency}
|
||||
{price.price}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-custom-text-300 transition-all duration-300 animate-fade-in">
|
||||
per user per month
|
||||
</div>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<Loader className="flex flex-col items-center justify-center">
|
||||
<Loader.Item height="38px" width="14rem" />
|
||||
</Loader>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center w-full space-y-4 transition-all duration-300 animate-fade-in">
|
||||
<button
|
||||
className={cn(
|
||||
upgradeButtonStyle,
|
||||
"relative inline-flex items-center justify-center w-56 px-4 py-2 text-sm font-medium rounded-lg focus:outline-none"
|
||||
)}
|
||||
onClick={() => {
|
||||
if (product && price.id) {
|
||||
handleCheckout({
|
||||
planVariant,
|
||||
productId: product.id,
|
||||
priceId: price.id,
|
||||
});
|
||||
}
|
||||
}}
|
||||
disabled={!!upgradeLoaderType}
|
||||
>
|
||||
{upgradeLoaderType === planVariant ? "Redirecting to Stripe" : (upgradeCTA ?? `Upgrade to ${planeName}`)}
|
||||
</button>
|
||||
{isTrialAllowed && !isSelfHosted && (
|
||||
<div className="mt-4 h-4 transition-all duration-300 animate-fade-in">
|
||||
{renderTrialButton &&
|
||||
renderTrialButton({
|
||||
productId: product?.id,
|
||||
priceId: price.id,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
43
web/core/components/license/modal/card/free-plan.tsx
Normal file
43
web/core/components/license/modal/card/free-plan.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { CircleX } from "lucide-react";
|
||||
// plane constants
|
||||
import { FREE_PLAN_UPGRADE_FEATURES } from "@plane/constants";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
|
||||
type FreePlanCardProps = {
|
||||
isOnFreePlan: boolean;
|
||||
};
|
||||
|
||||
export const FreePlanCard = observer((props: FreePlanCardProps) => {
|
||||
const { isOnFreePlan } = props;
|
||||
return (
|
||||
<div className="py-4 px-2 border border-custom-border-200 rounded-xl">
|
||||
{isOnFreePlan && (
|
||||
<div className="py-2 px-3">
|
||||
<span className="px-2 py-1 bg-custom-background-90 text-sm text-custom-text-300 font-medium rounded">
|
||||
Your plan
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="px-4 py-2 font-semibold">
|
||||
<div className="text-2xl">Free</div>
|
||||
<div className="text-sm text-custom-text-300">$0 per user per month</div>
|
||||
</div>
|
||||
<div className="px-2 pt-2 pb-3">
|
||||
<ul className="w-full grid grid-cols-12 gap-x-4">
|
||||
{FREE_PLAN_UPGRADE_FEATURES.map((feature) => (
|
||||
<li key={feature} className={cn("col-span-12 relative rounded-md p-2 flex")}>
|
||||
<p className="w-full text-sm font-medium leading-5 flex items-center">
|
||||
<CircleX className="h-4 w-4 mr-2 text-red-500 flex-shrink-0" />
|
||||
<span className="text-custom-text-200 truncate">{feature}</span>
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
4
web/core/components/license/modal/card/index.ts
Normal file
4
web/core/components/license/modal/card/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export * from "./base-paid-plan-card";
|
||||
export * from "./free-plan";
|
||||
export * from "./talk-to-sales";
|
||||
export * from "./plan-upgrade";
|
||||
112
web/core/components/license/modal/card/plan-upgrade.tsx
Normal file
112
web/core/components/license/modal/card/plan-upgrade.tsx
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
"use client";
|
||||
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { EProductSubscriptionEnum, TALK_TO_SALES_URL } from "@plane/constants";
|
||||
import { IPaymentProduct, TSubscriptionPrice } from "@plane/types";
|
||||
import { calculateYearlyDiscount, cn, getSubscriptionName, getSubscriptionPriceDetails } from "@plane/utils";
|
||||
// components
|
||||
import { BasePaidPlanCard, TalkToSalesCard } from "@/components/license";
|
||||
// helpers
|
||||
import { getDiscountPillStyle } from "@/components/workspace/billing/subscription";
|
||||
// local components
|
||||
import { PlanCheckoutButton, TCheckoutParams } from "./checkout-button";
|
||||
|
||||
export type PlanUpgradeCardProps = {
|
||||
planVariant: EProductSubscriptionEnum;
|
||||
isLoading?: boolean;
|
||||
product: IPaymentProduct | undefined;
|
||||
features: string[];
|
||||
upgradeCTA?: string;
|
||||
upgradeLoaderType?: Omit<EProductSubscriptionEnum, "FREE"> | undefined;
|
||||
verticalFeatureList?: boolean;
|
||||
extraFeatures?: string | React.ReactNode;
|
||||
renderTrialButton?: (props: { productId: string | undefined; priceId: string | undefined }) => React.ReactNode;
|
||||
handleCheckout: (params: TCheckoutParams) => void;
|
||||
isSelfHosted: boolean;
|
||||
isTrialAllowed: boolean;
|
||||
};
|
||||
|
||||
export const PlanUpgradeCard: FC<PlanUpgradeCardProps> = observer((props) => {
|
||||
const {
|
||||
planVariant,
|
||||
features,
|
||||
isLoading,
|
||||
product,
|
||||
upgradeCTA,
|
||||
verticalFeatureList = false,
|
||||
extraFeatures,
|
||||
upgradeLoaderType,
|
||||
renderTrialButton,
|
||||
handleCheckout,
|
||||
isSelfHosted,
|
||||
isTrialAllowed,
|
||||
} = props;
|
||||
// price details
|
||||
const planeName = getSubscriptionName(planVariant);
|
||||
const { monthlyPriceDetails, yearlyPriceDetails } = getSubscriptionPriceDetails(product);
|
||||
const yearlyDiscount = calculateYearlyDiscount(monthlyPriceDetails.price, yearlyPriceDetails.price);
|
||||
const prices = [monthlyPriceDetails, yearlyPriceDetails];
|
||||
|
||||
if (!product?.is_active) {
|
||||
return (
|
||||
<TalkToSalesCard
|
||||
planVariant={planVariant}
|
||||
href={TALK_TO_SALES_URL}
|
||||
isLoading={isLoading}
|
||||
features={features}
|
||||
prices={prices}
|
||||
upgradeLoaderType={upgradeLoaderType}
|
||||
verticalFeatureList={verticalFeatureList}
|
||||
extraFeatures={extraFeatures}
|
||||
isSelfHosted={isSelfHosted}
|
||||
isTrialAllowed={isTrialAllowed}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const renderPriceContent = (price: TSubscriptionPrice) => (
|
||||
<>
|
||||
{price.recurring === "month" && "Monthly"}
|
||||
{price.recurring === "year" && (
|
||||
<>
|
||||
Yearly
|
||||
{yearlyDiscount > 0 && (
|
||||
<span className={cn(getDiscountPillStyle(planVariant), "rounded-full px-1.5 py-0.5 ml-1 text-xs")}>
|
||||
-{yearlyDiscount}%
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<BasePaidPlanCard
|
||||
planVariant={planVariant}
|
||||
features={features}
|
||||
prices={prices}
|
||||
upgradeLoaderType={upgradeLoaderType}
|
||||
verticalFeatureList={verticalFeatureList}
|
||||
extraFeatures={extraFeatures}
|
||||
renderPriceContent={renderPriceContent}
|
||||
renderActionButton={(price) => (
|
||||
<PlanCheckoutButton
|
||||
planeName={planeName}
|
||||
planVariant={planVariant}
|
||||
isLoading={isLoading}
|
||||
product={product}
|
||||
price={price}
|
||||
upgradeCTA={upgradeCTA}
|
||||
upgradeLoaderType={upgradeLoaderType}
|
||||
renderTrialButton={renderTrialButton}
|
||||
handleCheckout={handleCheckout}
|
||||
isSelfHosted={isSelfHosted}
|
||||
isTrialAllowed={isTrialAllowed}
|
||||
/>
|
||||
)}
|
||||
isSelfHosted={isSelfHosted}
|
||||
/>
|
||||
);
|
||||
});
|
||||
104
web/core/components/license/modal/card/talk-to-sales.tsx
Normal file
104
web/core/components/license/modal/card/talk-to-sales.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
"use client";
|
||||
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// types
|
||||
import { EProductSubscriptionEnum } from "@plane/constants";
|
||||
// plane imports
|
||||
import { TSubscriptionPrice } from "@plane/types";
|
||||
import { getButtonStyling, Loader } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// plane web imports
|
||||
import { getUpgradeButtonStyle } from "@/components/workspace/billing/subscription";
|
||||
// local imports
|
||||
import { BasePaidPlanCard } from "./base-paid-plan-card";
|
||||
|
||||
export type TalkToSalesCardProps = {
|
||||
planVariant: EProductSubscriptionEnum;
|
||||
href: string;
|
||||
isLoading?: boolean;
|
||||
features: string[];
|
||||
prices: TSubscriptionPrice[];
|
||||
upgradeLoaderType: Omit<EProductSubscriptionEnum, "FREE"> | undefined;
|
||||
verticalFeatureList?: boolean;
|
||||
extraFeatures?: string | React.ReactNode;
|
||||
isSelfHosted: boolean;
|
||||
isTrialAllowed: boolean;
|
||||
};
|
||||
|
||||
export const TalkToSalesCard: FC<TalkToSalesCardProps> = observer((props) => {
|
||||
const {
|
||||
planVariant,
|
||||
href,
|
||||
features,
|
||||
prices,
|
||||
isLoading,
|
||||
verticalFeatureList = false,
|
||||
extraFeatures,
|
||||
upgradeLoaderType,
|
||||
isSelfHosted,
|
||||
isTrialAllowed,
|
||||
} = props;
|
||||
|
||||
const renderPriceContent = (price: TSubscriptionPrice) => (
|
||||
<>
|
||||
{price.recurring === "month" && "Monthly"}
|
||||
{price.recurring === "year" && "Yearly"}
|
||||
</>
|
||||
);
|
||||
|
||||
const renderActionButton = () => {
|
||||
const upgradeButtonStyle =
|
||||
getUpgradeButtonStyle(planVariant, !!upgradeLoaderType) ?? getButtonStyling("primary", "lg", !!upgradeLoaderType);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="pb-4 text-center">
|
||||
<div className="text-2xl font-semibold h-9 flex justify-center items-center">
|
||||
{isLoading ? (
|
||||
<Loader className="flex flex-col items-center justify-center">
|
||||
<Loader.Item height="36px" width="4rem" />
|
||||
</Loader>
|
||||
) : (
|
||||
<>Quote on request</>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-custom-text-300">per user per month</div>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<Loader className="flex flex-col items-center justify-center">
|
||||
<Loader.Item height="38px" width="14rem" />
|
||||
</Loader>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center w-full">
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
className={cn(
|
||||
upgradeButtonStyle,
|
||||
"relative inline-flex items-center justify-center w-56 px-4 py-2 text-sm font-medium rounded-lg focus:outline-none"
|
||||
)}
|
||||
>
|
||||
Talk to Sales
|
||||
</a>
|
||||
{isTrialAllowed && !isSelfHosted && <div className="mt-4 h-4" />}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<BasePaidPlanCard
|
||||
planVariant={planVariant}
|
||||
features={features}
|
||||
prices={prices}
|
||||
upgradeLoaderType={upgradeLoaderType}
|
||||
verticalFeatureList={verticalFeatureList}
|
||||
extraFeatures={extraFeatures}
|
||||
renderPriceContent={renderPriceContent}
|
||||
renderActionButton={renderActionButton}
|
||||
isSelfHosted={isSelfHosted}
|
||||
/>
|
||||
);
|
||||
});
|
||||
1
web/core/components/license/modal/index.ts
Normal file
1
web/core/components/license/modal/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./card";
|
||||
163
web/core/components/workspace/billing/comparison/base.tsx
Normal file
163
web/core/components/workspace/billing/comparison/base.tsx
Normal 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>
|
||||
);
|
||||
})
|
||||
);
|
||||
|
|
@ -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}</>;
|
||||
};
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export * from "./base";
|
||||
export * from "./feature-detail";
|
||||
1
web/core/components/workspace/billing/index.ts
Normal file
1
web/core/components/workspace/billing/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./comparison";
|
||||
213
web/core/components/workspace/billing/subscription.ts
Normal file
213
web/core/components/workspace/billing/subscription.ts
Normal 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");
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
1304
web/core/constants/plans.tsx
Normal file
1304
web/core/constants/plans.tsx
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue