[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

@ -14,6 +14,7 @@ export * from "./state";
export * from "./swr";
export * from "./tab-indices";
export * from "./user";
export * from "./payment";
export * from "./workspace";
export * from "./stickies";
export * from "./cycle";
@ -30,3 +31,4 @@ export * from "./spreadsheet";
export * from "./dashboard";
export * from "./page";
export * from "./emoji";
export * from "./subscription";

View file

@ -0,0 +1,163 @@
import { IPaymentProduct, TBillingFrequency, TProductBillingFrequency } from "@plane/types";
/**
* Enum representing different product subscription types
*/
export enum EProductSubscriptionEnum {
FREE = "FREE",
ONE = "ONE",
PRO = "PRO",
BUSINESS = "BUSINESS",
ENTERPRISE = "ENTERPRISE",
}
/**
* Default billing frequency for each product subscription type
*/
export const DEFAULT_PRODUCT_BILLING_FREQUENCY: TProductBillingFrequency = {
[EProductSubscriptionEnum.FREE]: undefined,
[EProductSubscriptionEnum.ONE]: undefined,
[EProductSubscriptionEnum.PRO]: "month",
[EProductSubscriptionEnum.BUSINESS]: "month",
[EProductSubscriptionEnum.ENTERPRISE]: "month",
};
/**
* Subscription types that support billing frequency toggle (monthly/yearly)
*/
export const SUBSCRIPTION_WITH_BILLING_FREQUENCY = [
EProductSubscriptionEnum.PRO,
EProductSubscriptionEnum.BUSINESS,
EProductSubscriptionEnum.ENTERPRISE,
];
/**
* Mapping of product subscription types to their respective payment product details
* Used to provide information about each product's pricing and features
*/
export const PLANE_COMMUNITY_PRODUCTS: Record<string, IPaymentProduct> = {
[EProductSubscriptionEnum.PRO]: {
id: EProductSubscriptionEnum.PRO,
name: "Plane Pro",
description:
"More views, more cycles powers, more pages features, new reports, and better dashboards are waiting to be unlocked.",
type: "PRO",
prices: [
{
id: `price_monthly_${EProductSubscriptionEnum.PRO}`,
unit_amount: 800,
recurring: "month",
currency: "usd",
workspace_amount: 800,
product: EProductSubscriptionEnum.PRO,
},
{
id: `price_yearly_${EProductSubscriptionEnum.PRO}`,
unit_amount: 7200,
recurring: "year",
currency: "usd",
workspace_amount: 7200,
product: EProductSubscriptionEnum.PRO,
},
],
payment_quantity: 1,
is_active: true,
},
[EProductSubscriptionEnum.BUSINESS]: {
id: EProductSubscriptionEnum.BUSINESS,
name: "Plane Business",
description:
"The earliest packaging of Business at $10 a seat a month billed annually, $12 a seat a month billed monthly for Plane Cloud",
type: "BUSINESS",
prices: [
{
id: `price_yearly_${EProductSubscriptionEnum.BUSINESS}`,
unit_amount: 0,
recurring: "year",
currency: "usd",
workspace_amount: 0,
product: EProductSubscriptionEnum.BUSINESS,
},
{
id: `price_monthly_${EProductSubscriptionEnum.BUSINESS}`,
unit_amount: 0,
recurring: "month",
currency: "usd",
workspace_amount: 0,
product: EProductSubscriptionEnum.BUSINESS,
},
],
payment_quantity: 1,
is_active: false,
},
[EProductSubscriptionEnum.ENTERPRISE]: {
id: EProductSubscriptionEnum.ENTERPRISE,
name: "Plane Enterprise",
description: "",
type: "ENTERPRISE",
prices: [
{
id: `price_yearly_${EProductSubscriptionEnum.ENTERPRISE}`,
unit_amount: 0,
recurring: "year",
currency: "usd",
workspace_amount: 0,
product: EProductSubscriptionEnum.ENTERPRISE,
},
{
id: `price_monthly_${EProductSubscriptionEnum.ENTERPRISE}`,
unit_amount: 0,
recurring: "month",
currency: "usd",
workspace_amount: 0,
product: EProductSubscriptionEnum.ENTERPRISE,
},
],
payment_quantity: 1,
is_active: false,
},
};
/**
* URL for the "Talk to Sales" page where users can contact sales team
*/
export const TALK_TO_SALES_URL = "https://plane.so/talk-to-sales";
/**
* Mapping of subscription types to their respective upgrade/redirection URLs based on billing frequency
* Used for self-hosted installations to redirect users to appropriate upgrade pages
*/
export const SUBSCRIPTION_REDIRECTION_URLS: Record<EProductSubscriptionEnum, Record<TBillingFrequency, string>> = {
[EProductSubscriptionEnum.FREE]: {
month: TALK_TO_SALES_URL,
year: TALK_TO_SALES_URL,
},
[EProductSubscriptionEnum.ONE]: {
month: TALK_TO_SALES_URL,
year: TALK_TO_SALES_URL,
},
[EProductSubscriptionEnum.PRO]: {
month: "https://app.plane.so/upgrade/pro/self-hosted?plan=month",
year: "https://app.plane.so/upgrade/pro/self-hosted?plan=year",
},
[EProductSubscriptionEnum.BUSINESS]: {
month: TALK_TO_SALES_URL,
year: TALK_TO_SALES_URL,
},
[EProductSubscriptionEnum.ENTERPRISE]: {
month: TALK_TO_SALES_URL,
year: TALK_TO_SALES_URL,
},
};
/**
* Mapping of subscription types to their respective marketing webpage URLs
* Used to direct users to learn more about each plan's features and pricing
*/
export const SUBSCRIPTION_WEBPAGE_URLS: Record<EProductSubscriptionEnum, string> = {
[EProductSubscriptionEnum.FREE]: TALK_TO_SALES_URL,
[EProductSubscriptionEnum.ONE]: TALK_TO_SALES_URL,
[EProductSubscriptionEnum.PRO]: "https://plane.so/pro",
[EProductSubscriptionEnum.BUSINESS]: "https://plane.so/business",
[EProductSubscriptionEnum.ENTERPRISE]: "https://plane.so/business",
};

View file

@ -0,0 +1,42 @@
export const ENTERPRISE_PLAN_FEATURES = [
"Private + managed deployments",
"GAC",
"LDAP support",
"Databases + Formulas",
"Unlimited and full Automation Flows",
"Full-suite professional services",
];
export const BUSINESS_PLAN_FEATURES = [
"Project Templates",
"Workflows + Approvals",
"Decision + Loops Automation",
"Custom Reports",
"Nested Pages",
"Intake Forms",
];
export const PRO_PLAN_FEATURES = [
"Dashboards + Reports",
"Full Time Tracking + Bulk Ops",
"Teamspaces",
"Trigger And Action",
"Wikis",
"Popular integrations",
];
export const ONE_PLAN_FEATURES = [
"OIDC + SAML for SSO",
"Active Cycles",
"Real-time collab + public views and page",
"Link pages in issues and vice-versa",
"Time-tracking + limited bulk ops",
"Docker, Kubernetes and more",
];
export const FREE_PLAN_UPGRADE_FEATURES = [
"OIDC + SAML for SSO",
"Time Tracking and Bulk Ops",
"Integrations",
"Public Views and Pages",
];

View file

@ -27,6 +27,7 @@ module.exports = {
theme: {
extend: {
boxShadow: {
"custom-shadow": "var(--color-shadow-custom)",
"custom-shadow-2xs": "var(--color-shadow-2xs)",
"custom-shadow-xs": "var(--color-shadow-xs)",
"custom-shadow-sm": "var(--color-shadow-sm)",
@ -208,6 +209,28 @@ module.exports = {
hover: "rgba(96, 100, 108, 0.25)",
active: "rgba(96, 100, 108, 0.7)",
},
subscription: {
free: {
200: convertToRGB("--color-subscription-free-200"),
400: convertToRGB("--color-subscription-free-400"),
},
one: {
200: convertToRGB("--color-subscription-one-200"),
400: convertToRGB("--color-subscription-one-400"),
},
pro: {
200: convertToRGB("--color-subscription-pro-200"),
400: convertToRGB("--color-subscription-pro-400"),
},
business: {
200: convertToRGB("--color-subscription-business-200"),
400: convertToRGB("--color-subscription-business-400"),
},
enterprise: {
200: convertToRGB("--color-subscription-enterprise-200"),
400: convertToRGB("--color-subscription-enterprise-400"),
},
},
},
onboarding: {
background: {

View file

@ -42,3 +42,4 @@ export * from "./charts";
export * from "./home";
export * from "./stickies";
export * from "./utils";
export * from "./payment";

36
packages/types/src/payment.d.ts vendored Normal file
View file

@ -0,0 +1,36 @@
import { EProductSubscriptionEnum } from "@plane/constants";
export type TBillingFrequency = "month" | "year";
export type IPaymentProductPrice = {
currency: string;
id: string;
product: string;
recurring: TBillingFrequency;
unit_amount: number;
workspace_amount: number;
};
export type TProductSubscriptionType = "FREE" | "ONE" | "PRO" | "BUSINESS" | "ENTERPRISE";
export type IPaymentProduct = {
description: string;
id: string;
name: string;
type: Omit<TProductSubscriptionType, "FREE">;
payment_quantity: number;
prices: IPaymentProductPrice[];
is_active: boolean;
};
export type TSubscriptionPrice = {
key: string;
id: string | undefined;
currency: string;
price: number;
recurring: TBillingFrequency;
};
export type TProductBillingFrequency = {
[key in EProductSubscriptionEnum]: TBillingFrequency | undefined;
};

View file

@ -12,3 +12,4 @@ export * from "./string";
export * from "./theme";
export * from "./workspace";
export * from "./work-item";
export * from "./subscription";

View file

@ -0,0 +1,106 @@
import orderBy from "lodash/orderBy";
// plane imports
import { EProductSubscriptionEnum } from "@plane/constants";
import { IPaymentProduct, TProductSubscriptionType, TSubscriptionPrice } from "@plane/types";
/**
* Calculates the yearly discount percentage when switching from monthly to yearly billing
* @param monthlyPrice - The monthly subscription price
* @param yearlyPricePerMonth - The monthly equivalent price when billed yearly
* @returns The discount percentage as a whole number (floored)
*/
export const calculateYearlyDiscount = (monthlyPrice: number, yearlyPricePerMonth: number): number => {
const monthlyCost = monthlyPrice * 12;
const yearlyCost = yearlyPricePerMonth * 12;
const amountSaved = monthlyCost - yearlyCost;
const discountPercentage = (amountSaved / monthlyCost) * 100;
return Math.floor(discountPercentage);
};
/**
* Gets the display name for a subscription plan variant
* @param planVariant - The subscription plan variant enum
* @returns The human-readable name of the plan
*/
export const getSubscriptionName = (planVariant: EProductSubscriptionEnum): string => {
switch (planVariant) {
case EProductSubscriptionEnum.FREE:
return "Free";
case EProductSubscriptionEnum.ONE:
return "One";
case EProductSubscriptionEnum.PRO:
return "Pro";
case EProductSubscriptionEnum.BUSINESS:
return "Business";
case EProductSubscriptionEnum.ENTERPRISE:
return "Enterprise";
default:
return "--";
}
};
/**
* Gets the base subscription name for upgrade/downgrade paths
* @param planVariant - The current subscription plan variant
* @param isSelfHosted - Whether the instance is self-hosted / community
* @returns The name of the base subscription plan
*
* @remarks
* - For self-hosted / community instances, the upgrade path differs from cloud instances
* - Returns the immediate lower tier subscription name
*/
export const getBaseSubscriptionName = (planVariant: TProductSubscriptionType, isSelfHosted: boolean): string => {
switch (planVariant) {
case EProductSubscriptionEnum.ONE:
return getSubscriptionName(EProductSubscriptionEnum.FREE);
case EProductSubscriptionEnum.PRO:
return isSelfHosted
? getSubscriptionName(EProductSubscriptionEnum.ONE)
: getSubscriptionName(EProductSubscriptionEnum.FREE);
case EProductSubscriptionEnum.BUSINESS:
return getSubscriptionName(EProductSubscriptionEnum.PRO);
case EProductSubscriptionEnum.ENTERPRISE:
return getSubscriptionName(EProductSubscriptionEnum.BUSINESS);
default:
return "--";
}
};
export type TSubscriptionPriceDetail = {
monthlyPriceDetails: TSubscriptionPrice;
yearlyPriceDetails: TSubscriptionPrice;
};
/**
* Gets the price details for a subscription product
* @param product - The payment product to get price details for
* @returns Array of price details for monthly and yearly plans
*/
export const getSubscriptionPriceDetails = (product: IPaymentProduct | undefined): TSubscriptionPriceDetail => {
const productPrices = product?.prices || [];
const monthlyPriceDetails = orderBy(productPrices, ["recurring"], ["desc"])?.find(
(price) => price.recurring === "month"
);
const monthlyPriceAmount = Number(((monthlyPriceDetails?.unit_amount || 0) / 100).toFixed(2));
const yearlyPriceDetails = orderBy(productPrices, ["recurring"], ["desc"])?.find(
(price) => price.recurring === "year"
);
const yearlyPriceAmount = Number(((yearlyPriceDetails?.unit_amount || 0) / 1200).toFixed(2));
return {
monthlyPriceDetails: {
key: "monthly",
id: monthlyPriceDetails?.id,
currency: "$",
price: monthlyPriceAmount,
recurring: "month",
},
yearlyPriceDetails: {
key: "yearly",
id: yearlyPriceDetails?.id,
currency: "$",
price: yearlyPriceAmount,
recurring: "year",
},
};
};

View file

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

View file

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

View file

@ -0,0 +1,124 @@
"use client";
import { FC } from "react";
import { observer } from "mobx-react";
// plane imports
import {
BUSINESS_PLAN_FEATURES,
ENTERPRISE_PLAN_FEATURES,
EProductSubscriptionEnum,
PLANE_COMMUNITY_PRODUCTS,
PRO_PLAN_FEATURES,
SUBSCRIPTION_REDIRECTION_URLS,
SUBSCRIPTION_WEBPAGE_URLS,
TALK_TO_SALES_URL,
} from "@plane/constants";
import { EModalWidth, ModalCore } from "@plane/ui";
import { cn } from "@plane/utils";
// components
import { FreePlanCard, PlanUpgradeCard } from "@/components/license";
import { TCheckoutParams } from "@/components/license/modal/card/checkout-button";
// Constants
const COMMON_CARD_CLASSNAME = "flex flex-col w-full h-full justify-end col-span-12 sm:col-span-6 xl:col-span-3";
const COMMON_EXTRA_FEATURES_CLASSNAME = "pt-2 text-center text-xs text-custom-primary-200 font-medium hover:underline";
export type PaidPlanUpgradeModalProps = {
isOpen: boolean;
handleClose: () => void;
};
export const PaidPlanUpgradeModal: FC<PaidPlanUpgradeModalProps> = observer((props) => {
const { isOpen, handleClose } = props;
// derived values
const isSelfHosted = true;
const isTrialAllowed = false;
const handleRedirection = ({ planVariant, priceId }: TCheckoutParams) => {
// Get the product and price using plane community constants
const product = PLANE_COMMUNITY_PRODUCTS[planVariant];
const price = product.prices.find((price) => price.id === priceId);
const frequency = price?.recurring ?? "year";
// Redirect to the appropriate URL
const redirectUrl = SUBSCRIPTION_REDIRECTION_URLS[planVariant][frequency] ?? TALK_TO_SALES_URL;
window.open(redirectUrl, "_blank");
};
return (
<ModalCore isOpen={isOpen} handleClose={handleClose} width={EModalWidth.VIIXL} className="rounded-2xl">
<div className="p-10 max-h-[90vh] overflow-auto">
<div className="grid grid-cols-12 gap-6 h-full">
{/* Free Plan Section */}
<div className={cn(COMMON_CARD_CLASSNAME)}>
<div className="text-3xl font-bold leading-8 flex">Upgrade to a paid plan and unlock missing features.</div>
<div className="mt-4 mb-2">
<p className="text-sm mb-4 pr-8 text-custom-text-100">
Dashboards, Workflows, Approvals, Time Management, and other superpowers are just a click away. Upgrade
today to unlock features your teams need yesterday.
</p>
</div>
{/* Free plan details */}
<FreePlanCard isOnFreePlan />
</div>
{/* Pro plan */}
<div className={cn(COMMON_CARD_CLASSNAME)}>
<PlanUpgradeCard
planVariant={EProductSubscriptionEnum.PRO}
product={PLANE_COMMUNITY_PRODUCTS[EProductSubscriptionEnum.PRO]}
features={PRO_PLAN_FEATURES}
verticalFeatureList
extraFeatures={
<p className={COMMON_EXTRA_FEATURES_CLASSNAME}>
<a href={SUBSCRIPTION_WEBPAGE_URLS[EProductSubscriptionEnum.PRO]} target="_blank">
See full features list
</a>
</p>
}
handleCheckout={handleRedirection}
isSelfHosted={!!isSelfHosted}
isTrialAllowed={!!isTrialAllowed}
/>
</div>
<div className={cn(COMMON_CARD_CLASSNAME)}>
<PlanUpgradeCard
planVariant={EProductSubscriptionEnum.BUSINESS}
product={PLANE_COMMUNITY_PRODUCTS[EProductSubscriptionEnum.BUSINESS]}
features={BUSINESS_PLAN_FEATURES}
verticalFeatureList
extraFeatures={
<p className={COMMON_EXTRA_FEATURES_CLASSNAME}>
<a href={SUBSCRIPTION_WEBPAGE_URLS[EProductSubscriptionEnum.BUSINESS]} target="_blank">
See full features list
</a>
</p>
}
handleCheckout={handleRedirection}
isSelfHosted={!!isSelfHosted}
isTrialAllowed={!!isTrialAllowed}
/>
</div>
<div className={cn(COMMON_CARD_CLASSNAME)}>
<PlanUpgradeCard
planVariant={EProductSubscriptionEnum.ENTERPRISE}
product={PLANE_COMMUNITY_PRODUCTS[EProductSubscriptionEnum.ENTERPRISE]}
features={ENTERPRISE_PLAN_FEATURES}
verticalFeatureList
extraFeatures={
<p className={COMMON_EXTRA_FEATURES_CLASSNAME}>
<a href={SUBSCRIPTION_WEBPAGE_URLS[EProductSubscriptionEnum.ENTERPRISE]} target="_blank">
See full features list
</a>
</p>
}
handleCheckout={handleRedirection}
isSelfHosted={!!isSelfHosted}
isTrialAllowed={!!isTrialAllowed}
/>
</div>
</div>
</div>
</ModalCore>
);
});

View file

@ -0,0 +1,63 @@
import { FC } from "react";
// plane imports
import { observer } from "mobx-react";
import { EProductSubscriptionEnum } from "@plane/constants";
import { TBillingFrequency } from "@plane/types";
import { calculateYearlyDiscount, cn } from "@plane/utils";
// plane web imports
import { getDiscountPillStyle, getSubscriptionBackgroundColor } from "@/components/workspace/billing/subscription";
type TPlanFrequencyToggleProps = {
subscriptionType: EProductSubscriptionEnum;
monthlyPrice: number;
yearlyPrice: number;
selectedFrequency: TBillingFrequency;
setSelectedFrequency: (frequency: TBillingFrequency) => void;
};
export const PlanFrequencyToggle: FC<TPlanFrequencyToggleProps> = observer((props) => {
const { subscriptionType, monthlyPrice, yearlyPrice, selectedFrequency, setSelectedFrequency } = props;
// derived values
const yearlyDiscount = calculateYearlyDiscount(monthlyPrice, yearlyPrice);
return (
<div className="flex w-full items-center cursor-pointer py-1 animate-slide-up">
<div
className={cn(
"flex space-x-1 rounded-md bg-custom-primary-200/10 p-0.5 w-full",
getSubscriptionBackgroundColor(subscriptionType, "50")
)}
>
<div
key="month"
onClick={() => setSelectedFrequency("month")}
className={cn(
"w-full rounded px-1 py-0.5 text-xs font-medium leading-5 text-center",
selectedFrequency === "month"
? "bg-custom-background-100 text-custom-text-100 shadow"
: "text-custom-text-300 hover:text-custom-text-200"
)}
>
Monthly
</div>
<div
key="year"
onClick={() => setSelectedFrequency("year")}
className={cn(
"w-full rounded px-1 py-0.5 text-xs font-medium leading-5 text-center",
selectedFrequency === "year"
? "bg-custom-background-100 text-custom-text-100 shadow"
: "text-custom-text-300 hover:text-custom-text-200"
)}
>
Yearly
{yearlyDiscount > 0 && (
<span className={cn(getDiscountPillStyle(subscriptionType), "rounded-full px-1 py-0.5 ml-1 text-[9px]")}>
-{yearlyDiscount}%
</span>
)}
</div>
</div>
</div>
);
});

View file

@ -0,0 +1,103 @@
import { FC } from "react";
import { observer } from "mobx-react";
// plane imports
import {
EProductSubscriptionEnum,
SUBSCRIPTION_REDIRECTION_URLS,
SUBSCRIPTION_WITH_BILLING_FREQUENCY,
TALK_TO_SALES_URL,
} from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { TBillingFrequency } from "@plane/types";
import { getButtonStyling } from "@plane/ui";
import { cn, getSubscriptionName } from "@plane/utils";
// constants
import { getUpgradeButtonStyle } from "@/components/workspace/billing/subscription";
import { TPlanDetail } from "@/constants/plans";
// components
import { PlanFrequencyToggle } from "./frequency-toggle";
type TPlanDetailProps = {
subscriptionType: EProductSubscriptionEnum;
planDetail: TPlanDetail;
billingFrequency: TBillingFrequency | undefined;
setBillingFrequency: (frequency: TBillingFrequency) => void;
};
const COMMON_BUTTON_STYLE =
"relative inline-flex items-center justify-center w-full px-4 py-1.5 text-xs font-medium rounded-lg focus:outline-none transition-all duration-300 animate-slide-up";
export const PlanDetail: FC<TPlanDetailProps> = observer((props) => {
const { subscriptionType, planDetail, billingFrequency, setBillingFrequency } = props;
// plane hooks
const { t } = useTranslation();
// subscription details
const subscriptionName = getSubscriptionName(subscriptionType);
const isSubscriptionActive = planDetail.isActive;
// pricing details
const displayPrice = billingFrequency === "month" ? planDetail.monthlyPrice : planDetail.yearlyPrice;
const pricingDescription = isSubscriptionActive ? "a user per month" : "Quote on request";
const pricingSecondaryDescription =
billingFrequency === "month"
? planDetail.monthlyPriceSecondaryDescription
: planDetail.yearlyPriceSecondaryDescription;
// helper styles
const upgradeButtonStyle = getUpgradeButtonStyle(subscriptionType, false) ?? getButtonStyling("primary", "lg");
const handleRedirection = () => {
const frequency = billingFrequency ?? "year";
// Get the redirection URL based on the subscription type and billing frequency
const redirectUrl = SUBSCRIPTION_REDIRECTION_URLS[subscriptionType][frequency] ?? TALK_TO_SALES_URL;
// Open the URL in a new tab
window.open(redirectUrl, "_blank");
};
return (
<div className="flex flex-col justify-between col-span-1 p-3 space-y-0.5">
{/* Plan name and pricing section */}
<div className="flex flex-col items-start">
<div className="flex w-full gap-2 items-center text-xl font-medium">
<span className="transition-all duration-300">{subscriptionName}</span>
{subscriptionType === EProductSubscriptionEnum.PRO && (
<span className="px-2 rounded text-custom-primary-200 bg-custom-primary-100/20 text-xs">Popular</span>
)}
</div>
<div className="flex gap-x-2 items-start text-custom-text-300 pb-1 transition-all duration-300 animate-slide-up">
{isSubscriptionActive && displayPrice !== undefined && (
<span className="text-custom-text-100 text-2xl font-semibold transition-all duration-300">
{"$" + displayPrice}
</span>
)}
<div className="pt-2">
{pricingDescription && <div className="transition-all duration-300">{pricingDescription}</div>}
{pricingSecondaryDescription && (
<div className="text-xs text-custom-text-400 transition-all duration-300">
{pricingSecondaryDescription}
</div>
)}
</div>
</div>
</div>
{/* Billing frequency toggle */}
{SUBSCRIPTION_WITH_BILLING_FREQUENCY.includes(subscriptionType) && billingFrequency && (
<div className="h-8 py-0.5">
<PlanFrequencyToggle
subscriptionType={subscriptionType}
monthlyPrice={planDetail.monthlyPrice || 0}
yearlyPrice={planDetail.yearlyPrice || 0}
selectedFrequency={billingFrequency}
setSelectedFrequency={setBillingFrequency}
/>
</div>
)}
{/* Subscription button */}
<div className={cn("flex flex-col gap-1 py-3 items-start transition-all duration-300")}>
<button onClick={handleRedirection} className={cn(upgradeButtonStyle, COMMON_BUTTON_STYLE)}>
{isSubscriptionActive ? `Upgrade to ${subscriptionName}` : t("common.upgrade_cta.talk_to_sales")}
</button>
</div>
</div>
);
});

View file

@ -0,0 +1,61 @@
import { forwardRef } from "react";
import { observer } from "mobx-react";
// plane imports
import { EProductSubscriptionEnum } from "@plane/constants";
import { TBillingFrequency } from "@plane/types";
// components
import { PlansComparisonBase, shouldRenderPlanDetail } from "@/components/workspace/billing/comparison/base";
import { PLANE_PLANS, TPlanePlans } from "@/constants/plans";
// plane web imports
import { PlanDetail } from "./plan-detail";
type TPlansComparisonProps = {
isScrolled: boolean;
isCompareAllFeaturesSectionOpen: boolean;
getBillingFrequency: (subscriptionType: EProductSubscriptionEnum) => TBillingFrequency | undefined;
setBillingFrequency: (subscriptionType: EProductSubscriptionEnum, frequency: TBillingFrequency) => void;
setIsCompareAllFeaturesSectionOpen: React.Dispatch<React.SetStateAction<boolean>>;
setIsScrolled: React.Dispatch<React.SetStateAction<boolean>>;
};
export const PlansComparison = observer(
forwardRef<HTMLDivElement, TPlansComparisonProps>(function PlansComparison(
props: TPlansComparisonProps,
ref: React.Ref<HTMLDivElement>
) {
const {
isScrolled,
isCompareAllFeaturesSectionOpen,
getBillingFrequency,
setBillingFrequency,
setIsCompareAllFeaturesSectionOpen,
setIsScrolled,
} = props;
// plan details
const { planDetails } = PLANE_PLANS;
return (
<PlansComparisonBase
ref={ref}
planeDetails={Object.entries(planDetails).map(([planKey, plan]) => {
const currentPlanKey = planKey as TPlanePlans;
if (!shouldRenderPlanDetail(currentPlanKey)) return null;
return (
<PlanDetail
key={planKey}
subscriptionType={plan.id}
planDetail={plan}
billingFrequency={getBillingFrequency(plan.id)}
setBillingFrequency={(frequency) => setBillingFrequency(plan.id, frequency)}
/>
);
})}
isSelfManaged
isScrolled={isScrolled}
isCompareAllFeaturesSectionOpen={isCompareAllFeaturesSectionOpen}
setIsCompareAllFeaturesSectionOpen={setIsCompareAllFeaturesSectionOpen}
setIsScrolled={setIsScrolled}
/>
);
})
);

View file

@ -1,27 +1,100 @@
import { MARKETING_PRICING_PAGE_LINK } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/ui";
import { useEffect, useRef, useState } from "react";
import { observer } from "mobx-react";
// plane imports
import {
DEFAULT_PRODUCT_BILLING_FREQUENCY,
EProductSubscriptionEnum,
SUBSCRIPTION_WITH_BILLING_FREQUENCY,
} from "@plane/constants";
import { TBillingFrequency, TProductBillingFrequency } from "@plane/types";
import { cn } from "@plane/utils";
// components
import { getSubscriptionTextColor } from "@/components/workspace/billing/subscription";
// local imports
import { PlansComparison } from "./comparison/root";
export const BillingRoot = observer(() => {
const [isScrolled, setIsScrolled] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const [isCompareAllFeaturesSectionOpen, setIsCompareAllFeaturesSectionOpen] = useState(false);
const [productBillingFrequency, setProductBillingFrequency] = useState<TProductBillingFrequency>(
DEFAULT_PRODUCT_BILLING_FREQUENCY
);
/**
* Retrieves the billing frequency for a given subscription type
* @param {EProductSubscriptionEnum} subscriptionType - Type of subscription to get frequency for
* @returns {TBillingFrequency | undefined} - Billing frequency if subscription supports it, undefined otherwise
*/
const getBillingFrequency = (subscriptionType: EProductSubscriptionEnum): TBillingFrequency | undefined =>
SUBSCRIPTION_WITH_BILLING_FREQUENCY.includes(subscriptionType)
? productBillingFrequency[subscriptionType]
: undefined;
/**
* Updates the billing frequency for a specific subscription type
* @param {EProductSubscriptionEnum} subscriptionType - Type of subscription to update
* @param {TBillingFrequency} frequency - New billing frequency to set
* @returns {void}
*/
const setBillingFrequency = (subscriptionType: EProductSubscriptionEnum, frequency: TBillingFrequency): void =>
setProductBillingFrequency({ ...productBillingFrequency, [subscriptionType]: frequency });
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const handleScroll = () => {
const scrollTop = container.scrollTop;
const isScrolled = isCompareAllFeaturesSectionOpen ? scrollTop > 0 : false;
setIsScrolled(isScrolled);
};
container.addEventListener("scroll", handleScroll);
return () => container.removeEventListener("scroll", handleScroll);
}, [isCompareAllFeaturesSectionOpen]);
export const BillingRoot = () => {
const { t } = useTranslation();
return (
<section className="w-full overflow-y-auto">
<section className="relative size-full flex flex-col overflow-y-auto scrollbar-hide">
<div>
<div className="flex items-center border-b border-custom-border-100 pb-3.5">
<h3 className="text-xl font-medium">{t("workspace_settings.settings.billing_and_plans.title")}</h3>
<div className="flex items-center">
<h3 className="text-xl font-medium flex gap-4">Billing and plans</h3>
</div>
</div>
<div
className={cn(
"transition-all duration-500 ease-in-out will-change-[height,opacity]",
isScrolled ? "h-0 opacity-0 pointer-events-none" : "h-[300px] opacity-100"
)}
>
<div className="py-6">
<div>
<h4 className="text-md mb-1 leading-6">{t("workspace_settings.settings.billing_and_plans.current_plan")}</h4>
<p className="mb-3 text-sm text-custom-text-200">
{t("workspace_settings.settings.billing_and_plans.free_plan")}
</p>
<a href={MARKETING_PRICING_PAGE_LINK} target="_blank" rel="noreferrer">
<Button variant="neutral-primary">{t("workspace_settings.settings.billing_and_plans.view_plans")}</Button>
</a>
<div className={cn("px-6 py-4 border border-custom-border-200 rounded-lg")}>
<div className="flex gap-2 font-medium items-center justify-between">
<div className="flex flex-col gap-1">
<h4
className={cn("text-xl leading-6 font-bold", getSubscriptionTextColor(EProductSubscriptionEnum.FREE))}
>
Community
</h4>
<div className="text-sm text-custom-text-200 font-medium">
Unlimited projects, issues, cycles, modules, pages, and storage
</div>
</div>
</div>
</div>
</div>
<div className="text-xl font-semibold mt-3">All plans</div>
</div>
<PlansComparison
ref={containerRef}
isScrolled={isScrolled}
isCompareAllFeaturesSectionOpen={isCompareAllFeaturesSectionOpen}
getBillingFrequency={getBillingFrequency}
setBillingFrequency={setBillingFrequency}
setIsCompareAllFeaturesSectionOpen={setIsCompareAllFeaturesSectionOpen}
setIsScrolled={setIsScrolled}
/>
</section>
);
};
});

View file

@ -1,18 +1,15 @@
import { useState } from "react";
import { observer } from "mobx-react";
import packageJson from "package.json";
import { useTranslation } from "@plane/i18n";
// ui
import { Button, Tooltip } from "@plane/ui";
// hooks
import { usePlatformOS } from "@/hooks/use-platform-os";
// assets
// local components
import { PaidPlanUpgradeModal } from "./upgrade";
import { PaidPlanUpgradeModal } from "../license";
export const WorkspaceEditionBadge = observer(() => {
const { isMobile } = usePlatformOS();
const { t } = useTranslation();
// states
const [isPaidPlanPurchaseModalOpen, setIsPaidPlanPurchaseModalOpen] = useState(false);
@ -29,7 +26,7 @@ export const WorkspaceEditionBadge = observer(() => {
className="w-fit min-w-24 cursor-pointer rounded-2xl px-2 py-1 text-center text-sm font-medium outline-none"
onClick={() => setIsPaidPlanPurchaseModalOpen(true)}
>
{t("sidebar.upgrade")}
Community
</Button>
</Tooltip>
</>

View file

@ -1,3 +0,0 @@
export * from "./pro-plan-upgrade";
export * from "./one-plan-upgrade";
export * from "./paid-plans-upgrade-modal";

View file

@ -1,55 +0,0 @@
import { FC } from "react";
import { CheckCircle } from "lucide-react";
// helpers
import { cn } from "@/helpers/common.helper";
export type OnePlanUpgradeProps = {
features: string[];
verticalFeatureList?: boolean;
extraFeatures?: string | React.ReactNode;
};
export const OnePlanUpgrade: FC<OnePlanUpgradeProps> = (props) => {
const { features, verticalFeatureList = false, extraFeatures } = props;
// env
const PLANE_ONE_PAYMENT_URL = "https://prime.plane.so/";
return (
<div className="py-4 px-2 border border-custom-border-90 rounded-xl bg-custom-background-90">
<div className="flex w-full justify-center h-10" />
<div className="pt-6 pb-4 text-center font-semibold">
<div className="text-2xl">Plane One</div>
<div className="text-3xl">$799</div>
<div className="text-sm text-custom-text-300">for two years support and updates</div>
</div>
<div className="flex justify-center w-full">
<a
href={PLANE_ONE_PAYMENT_URL}
target="_blank"
className="relative inline-flex items-center justify-center w-56 px-4 py-2.5 text-white text-sm font-medium border border-[#525252] bg-gradient-to-r from-[#353535] via-[#1111118C] to-[#21212153] rounded-lg focus:outline-none"
>
Upgrade to One
</a>
</div>
<div className="px-2 pt-6 pb-2">
<div className="p-2 text-sm font-semibold">Everything in Free +</div>
<ul className="w-full 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">
<CheckCircle className="h-4 w-4 mr-4 text-custom-text-300 flex-shrink-0" />
<span className="text-custom-text-200 truncate">{feature}</span>
</p>
</li>
))}
</ul>
{extraFeatures && <div>{extraFeatures}</div>}
</div>
</div>
);
};

View file

@ -1,113 +0,0 @@
import { FC } from "react";
// types
import { CircleX } from "lucide-react";
// services
import { EModalWidth, ModalCore } from "@plane/ui";
// plane web components
import { cn } from "@/helpers/common.helper";
// local components
import { OnePlanUpgrade } from "./one-plan-upgrade";
import { ProPlanUpgrade } from "./pro-plan-upgrade";
const PRO_PLAN_FEATURES = [
"More Cycles features",
"Full Time Tracking + Bulk Ops",
"Workflow manager",
"Automations",
"Popular integrations",
"Plane AI",
];
const ONE_PLAN_FEATURES = [
"OIDC + SAML for SSO",
"Active Cycles",
"Real-time collab + public views and page",
"Link pages in work items and vice-versa",
"Time-tracking + limited bulk ops",
"Docker, Kubernetes and more",
];
const FREE_PLAN_UPGRADE_FEATURES = [
"OIDC + SAML for SSO",
"Time tracking and bulk ops",
"Integrations",
"Public views and pages",
];
export type PaidPlanUpgradeModalProps = {
isOpen: boolean;
handleClose: () => void;
};
export const PaidPlanUpgradeModal: FC<PaidPlanUpgradeModalProps> = (props) => {
const { isOpen, handleClose } = props;
return (
<ModalCore isOpen={isOpen} handleClose={handleClose} width={EModalWidth.VIXL} className="rounded-2xl">
<div className="p-10 max-h-[90vh] overflow-auto">
<div className="grid grid-cols-12 gap-6">
<div className="col-span-12 md:col-span-4">
<div className="text-3xl font-bold leading-8 flex">Upgrade to a paid plan and unlock missing features.</div>
<div className="mt-4 mb-12">
<p className="text-sm mb-4 pr-8 text-custom-text-100">
Active Cycles, time tracking, bulk ops, and other features are waiting for you on one of our paid plans.
Upgrade today to unlock features your teams need yesterday.
</p>
</div>
{/* Free plan details */}
<div className="py-4 px-2 border border-custom-border-90 rounded-xl">
<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-3xl">Free</div>
<div className="text-sm text-custom-text-300">$0 a 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-4 text-red-500 flex-shrink-0" />
<span className="text-custom-text-200 truncate">{feature}</span>
</p>
</li>
))}
</ul>
</div>
</div>
</div>
<div className="col-span-12 md:col-span-4">
<ProPlanUpgrade
basePlan="One"
features={PRO_PLAN_FEATURES}
verticalFeatureList
extraFeatures={
<p className="pt-1.5 text-center text-xs text-custom-primary-200 font-semibold underline">
<a href="https://plane.so/pro" target="_blank">
See full features list
</a>
</p>
}
/>
</div>
<div className="col-span-12 md:col-span-4">
<OnePlanUpgrade
features={ONE_PLAN_FEATURES}
verticalFeatureList
extraFeatures={
<p className="pt-1.5 text-center text-xs text-custom-primary-200 font-semibold underline">
<a href="https://plane.so/one" target="_blank">
See full features list
</a>
</p>
}
/>
</div>
</div>
</div>
</ModalCore>
);
};

View file

@ -1,127 +0,0 @@
"use client";
import { FC, useState } from "react";
import { CheckCircle } from "lucide-react";
import { Tab } from "@headlessui/react";
// helpers
import { cn } from "@/helpers/common.helper";
export type ProPlanUpgradeProps = {
basePlan: "Free" | "One";
features: string[];
verticalFeatureList?: boolean;
extraFeatures?: string | React.ReactNode;
};
type TProPiceFrequency = "month" | "year";
type TProPlanPrice = {
key: string;
currency: string;
price: number;
recurring: TProPiceFrequency;
};
// constants
export const calculateYearlyDiscount = (monthlyPrice: number, yearlyPricePerMonth: number): number => {
const monthlyCost = monthlyPrice * 12;
const yearlyCost = yearlyPricePerMonth * 12;
const amountSaved = monthlyCost - yearlyCost;
const discountPercentage = (amountSaved / monthlyCost) * 100;
return Math.floor(discountPercentage);
};
const PRO_PLAN_PRICES: TProPlanPrice[] = [
{ key: "monthly", currency: "$", price: 8, recurring: "month" },
{ key: "yearly", currency: "$", price: 6, recurring: "year" },
];
export const ProPlanUpgrade: FC<ProPlanUpgradeProps> = (props) => {
const { basePlan, features, verticalFeatureList = false, extraFeatures } = props;
// states
const [selectedPlan, setSelectedPlan] = useState<TProPiceFrequency>("month");
// derived
const monthlyPrice = PRO_PLAN_PRICES.find((price) => price.recurring === "month")?.price ?? 0;
const yearlyPrice = PRO_PLAN_PRICES.find((price) => price.recurring === "year")?.price ?? 0;
const yearlyDiscount = calculateYearlyDiscount(monthlyPrice, yearlyPrice);
// env
const PRO_PLAN_MONTHLY_PAYMENT_URL = "https://app.plane.so/upgrade/pro/self-hosted?plan=month";
const PRO_PLAN_YEARLY_PAYMENT_URL = "https://app.plane.so/upgrade/pro/self-hosted?plan=year";
return (
<div className="py-4 px-2 border border-custom-primary-200/30 rounded-xl bg-custom-primary-200/5">
<Tab.Group>
<div className="flex w-full justify-center h-10">
<Tab.List className="flex space-x-1 rounded-lg bg-custom-primary-200/10 p-1 w-60">
{PRO_PLAN_PRICES.map((price: TProPlanPrice) => (
<Tab
key={price.key}
className={({ selected }) =>
cn(
"w-full rounded-lg py-1.5 text-sm font-medium leading-5",
selected
? "bg-custom-background-100 text-custom-primary-300 shadow"
: "hover:bg-custom-primary-100/5 text-custom-text-300 hover:text-custom-text-200"
)
}
onClick={() => setSelectedPlan(price.recurring)}
>
<>
{price.recurring === "month" && ("Monthly" as string)}
{price.recurring === "year" && ("Yearly" as string)}
{price.recurring === "year" && (
<span className="bg-gradient-to-r from-[#C78401] to-[#896828] text-white rounded-full px-2 py-1 ml-1 text-xs">
-{yearlyDiscount}%
</span>
)}
</>
</Tab>
))}
</Tab.List>
</div>
<Tab.Panels>
{PRO_PLAN_PRICES.map((price: TProPlanPrice) => (
<Tab.Panel key={price.key}>
<div className="pt-6 pb-4 text-center font-semibold">
<div className="text-2xl">Plane Pro</div>
<div className="text-3xl">
{price.currency}
{price.price}
</div>
<div className="text-sm text-custom-text-300">a user per month</div>
</div>
<div className="flex justify-center w-full">
<a
href={selectedPlan === "month" ? PRO_PLAN_MONTHLY_PAYMENT_URL : PRO_PLAN_YEARLY_PAYMENT_URL}
target="_blank"
className="relative inline-flex items-center justify-center w-56 px-4 py-2.5 text-white text-sm font-medium border border-[#E9DBBF99]/60 bg-gradient-to-r from-[#C78401] to-[#896828] rounded-lg focus:outline-none"
>
Upgrade to Pro
</a>
</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-4 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>
);
};

View file

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

View 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>
);
});

View 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>
)}
</>
);
});

View 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>
);
});

View file

@ -0,0 +1,4 @@
export * from "./base-paid-plan-card";
export * from "./free-plan";
export * from "./talk-to-sales";
export * from "./plan-upgrade";

View 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}
/>
);
});

View 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}
/>
);
});

View file

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

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";

1304
web/core/constants/plans.tsx Normal file

File diff suppressed because it is too large Load diff

View file

@ -16,6 +16,23 @@
}
}
@layer utilities {
@keyframes slide-up {
from {
transform: translateY(10px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.animate-slide-up {
animation: slide-up 0.3s ease-out forwards;
}
}
@layer base {
html {
font-family: "Inter", sans-serif;
@ -76,25 +93,34 @@
--color-border-primary: var(--color-primary-40);
--color-border-error: var(--color-error-100);
--color-shadow-2xs: 0px 0px 1px 0px rgba(23, 23, 23, 0.06), 0px 1px 2px 0px rgba(23, 23, 23, 0.06),
--color-shadow-2xs:
0px 0px 1px 0px rgba(23, 23, 23, 0.06), 0px 1px 2px 0px rgba(23, 23, 23, 0.06),
0px 1px 2px 0px rgba(23, 23, 23, 0.14);
--color-shadow-xs: 0px 1px 2px 0px rgba(0, 0, 0, 0.16), 0px 2px 4px 0px rgba(16, 24, 40, 0.12),
--color-shadow-xs:
0px 1px 2px 0px rgba(0, 0, 0, 0.16), 0px 2px 4px 0px rgba(16, 24, 40, 0.12),
0px 1px 8px -1px rgba(16, 24, 40, 0.1);
--color-shadow-sm: 0px 1px 4px 0px rgba(0, 0, 0, 0.01), 0px 4px 8px 0px rgba(0, 0, 0, 0.02),
0px 1px 12px 0px rgba(0, 0, 0, 0.12);
--color-shadow-rg: 0px 3px 6px 0px rgba(0, 0, 0, 0.1), 0px 4px 4px 0px rgba(16, 24, 40, 0.08),
--color-shadow-sm:
0px 1px 4px 0px rgba(0, 0, 0, 0.01), 0px 4px 8px 0px rgba(0, 0, 0, 0.02), 0px 1px 12px 0px rgba(0, 0, 0, 0.12);
--color-shadow-rg:
0px 3px 6px 0px rgba(0, 0, 0, 0.1), 0px 4px 4px 0px rgba(16, 24, 40, 0.08),
0px 1px 12px 0px rgba(16, 24, 40, 0.04);
--color-shadow-md: 0px 4px 8px 0px rgba(0, 0, 0, 0.12), 0px 6px 12px 0px rgba(16, 24, 40, 0.12),
--color-shadow-md:
0px 4px 8px 0px rgba(0, 0, 0, 0.12), 0px 6px 12px 0px rgba(16, 24, 40, 0.12),
0px 1px 16px 0px rgba(16, 24, 40, 0.12);
--color-shadow-lg: 0px 6px 12px 0px rgba(0, 0, 0, 0.12), 0px 8px 16px 0px rgba(0, 0, 0, 0.12),
--color-shadow-lg:
0px 6px 12px 0px rgba(0, 0, 0, 0.12), 0px 8px 16px 0px rgba(0, 0, 0, 0.12),
0px 1px 24px 0px rgba(16, 24, 40, 0.12);
--color-shadow-xl: 0px 0px 18px 0px rgba(0, 0, 0, 0.16), 0px 0px 24px 0px rgba(16, 24, 40, 0.16),
--color-shadow-xl:
0px 0px 18px 0px rgba(0, 0, 0, 0.16), 0px 0px 24px 0px rgba(16, 24, 40, 0.16),
0px 0px 52px 0px rgba(16, 24, 40, 0.16);
--color-shadow-2xl: 0px 8px 16px 0px rgba(0, 0, 0, 0.12), 0px 12px 24px 0px rgba(16, 24, 40, 0.12),
--color-shadow-2xl:
0px 8px 16px 0px rgba(0, 0, 0, 0.12), 0px 12px 24px 0px rgba(16, 24, 40, 0.12),
0px 1px 32px 0px rgba(16, 24, 40, 0.12);
--color-shadow-3xl: 0px 12px 24px 0px rgba(0, 0, 0, 0.12), 0px 16px 32px 0px rgba(0, 0, 0, 0.12),
--color-shadow-3xl:
0px 12px 24px 0px rgba(0, 0, 0, 0.12), 0px 16px 32px 0px rgba(0, 0, 0, 0.12),
0px 1px 48px 0px rgba(16, 24, 40, 0.12);
--color-shadow-4xl: 0px 8px 40px 0px rgba(0, 0, 61, 0.05), 0px 12px 32px -16px rgba(0, 0, 0, 0.05);
--color-shadow-custom: 2px 2px 8px 2px rgba(234, 231, 250, 0.3);
--color-sidebar-background-100: var(--color-background-100); /* primary sidebar bg */
--color-sidebar-background-90: var(--color-background-90); /* secondary sidebar bg */
@ -155,6 +181,22 @@
--color-pi-800: 57, 56, 149;
--color-pi-900: 30, 29, 78;
--color-pi-950: 14, 14, 37;
/* Plane subscriptions */
--color-subscription-free-200: 69, 80, 104;
--color-subscription-free-400: 51, 59, 77;
--color-subscription-one-200: 101, 42, 14;
--color-subscription-one-400: 90, 37, 12;
--color-subscription-pro-200: 32, 128, 138;
--color-subscription-pro-400: 24, 96, 104;
--color-subscription-business-200: 142, 33, 87;
--color-subscription-business-400: 103, 24, 63;
--color-subscription-enterprise-200: 86, 5, 145;
--color-subscription-enterprise-400: 73, 4, 123;
}
[data-theme="light"] {
@ -269,6 +311,22 @@
--color-pi-800: 57, 56, 149;
--color-pi-900: 30, 29, 78;
--color-pi-950: 14, 14, 37;
/* Plane subscriptions */
--color-subscription-free-200: 206, 213, 232;
--color-subscription-free-400: 152, 166, 206;
--color-subscription-one-200: 255, 148, 153;
--color-subscription-one-400: 200, 118, 121;
--color-subscription-pro-200: 12, 170, 192;
--color-subscription-pro-400: 9, 130, 154;
--color-subscription-business-200: 206, 34, 119;
--color-subscription-business-400: 140, 23, 81;
--color-subscription-enterprise-200: 182, 71, 255;
--color-subscription-enterprise-400: 134, 11, 203;
}
[data-theme="dark"] {
@ -515,6 +573,7 @@ body {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
line-clamp: 1;
}
/* popover2 styling */
@ -731,6 +790,14 @@ div.web-view-spinner div.bar12 {
border: 3px solid rgba(0, 0, 0, 0);
}
.shadow-custom {
box-shadow: 2px 2px 8px 2px rgba(234, 231, 250, 0.3); /* Convert #EAE7FA4D to rgba */
}
/* backdrop filter */
.backdrop-blur-custom {
@apply backdrop-filter blur-[9px];
}
/* scrollbar sm size */
.scrollbar-sm::-webkit-scrollbar {
height: 12px;