diff --git a/packages/constants/src/index.ts b/packages/constants/src/index.ts index 74978c943..f974dd64b 100644 --- a/packages/constants/src/index.ts +++ b/packages/constants/src/index.ts @@ -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"; diff --git a/packages/constants/src/payment.ts b/packages/constants/src/payment.ts new file mode 100644 index 000000000..6c82b0e30 --- /dev/null +++ b/packages/constants/src/payment.ts @@ -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 = { + [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.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.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", +}; diff --git a/packages/constants/src/subscription.ts b/packages/constants/src/subscription.ts new file mode 100644 index 000000000..c2d2cfa13 --- /dev/null +++ b/packages/constants/src/subscription.ts @@ -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", +]; diff --git a/packages/tailwind-config/tailwind.config.js b/packages/tailwind-config/tailwind.config.js index 3e34ca1f0..700831d12 100644 --- a/packages/tailwind-config/tailwind.config.js +++ b/packages/tailwind-config/tailwind.config.js @@ -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: { diff --git a/packages/types/src/index.d.ts b/packages/types/src/index.d.ts index cb916a2f2..b6af3b562 100644 --- a/packages/types/src/index.d.ts +++ b/packages/types/src/index.d.ts @@ -42,3 +42,4 @@ export * from "./charts"; export * from "./home"; export * from "./stickies"; export * from "./utils"; +export * from "./payment"; diff --git a/packages/types/src/payment.d.ts b/packages/types/src/payment.d.ts new file mode 100644 index 000000000..bdbab7f32 --- /dev/null +++ b/packages/types/src/payment.d.ts @@ -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; + 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; +}; diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 57f10c5d4..177a7fc0c 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -12,3 +12,4 @@ export * from "./string"; export * from "./theme"; export * from "./workspace"; export * from "./work-item"; +export * from "./subscription"; diff --git a/packages/utils/src/subscription.ts b/packages/utils/src/subscription.ts new file mode 100644 index 000000000..207f387b7 --- /dev/null +++ b/packages/utils/src/subscription.ts @@ -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", + }, + }; +}; diff --git a/web/ce/components/license/index.ts b/web/ce/components/license/index.ts new file mode 100644 index 000000000..031608e25 --- /dev/null +++ b/web/ce/components/license/index.ts @@ -0,0 +1 @@ +export * from "./modal"; diff --git a/web/ce/components/license/modal/index.ts b/web/ce/components/license/modal/index.ts new file mode 100644 index 000000000..8add86e5d --- /dev/null +++ b/web/ce/components/license/modal/index.ts @@ -0,0 +1 @@ +export * from "./upgrade-modal"; diff --git a/web/ce/components/license/modal/upgrade-modal.tsx b/web/ce/components/license/modal/upgrade-modal.tsx new file mode 100644 index 000000000..3b209198a --- /dev/null +++ b/web/ce/components/license/modal/upgrade-modal.tsx @@ -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 = 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 ( + +
+
+ {/* Free Plan Section */} +
+
Upgrade to a paid plan and unlock missing features.
+
+

+ Dashboards, Workflows, Approvals, Time Management, and other superpowers are just a click away. Upgrade + today to unlock features your teams need yesterday. +

+
+ + {/* Free plan details */} + +
+ + {/* Pro plan */} +
+ + + See full features list + +

+ } + handleCheckout={handleRedirection} + isSelfHosted={!!isSelfHosted} + isTrialAllowed={!!isTrialAllowed} + /> +
+
+ + + See full features list + +

+ } + handleCheckout={handleRedirection} + isSelfHosted={!!isSelfHosted} + isTrialAllowed={!!isTrialAllowed} + /> +
+
+ + + See full features list + +

+ } + handleCheckout={handleRedirection} + isSelfHosted={!!isSelfHosted} + isTrialAllowed={!!isTrialAllowed} + /> +
+
+
+
+ ); +}); diff --git a/web/ce/components/workspace/billing/comparison/frequency-toggle.tsx b/web/ce/components/workspace/billing/comparison/frequency-toggle.tsx new file mode 100644 index 000000000..622c4d5ce --- /dev/null +++ b/web/ce/components/workspace/billing/comparison/frequency-toggle.tsx @@ -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 = observer((props) => { + const { subscriptionType, monthlyPrice, yearlyPrice, selectedFrequency, setSelectedFrequency } = props; + // derived values + const yearlyDiscount = calculateYearlyDiscount(monthlyPrice, yearlyPrice); + + return ( +
+
+
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 +
+
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 && ( + + -{yearlyDiscount}% + + )} +
+
+
+ ); +}); diff --git a/web/ce/components/workspace/billing/comparison/plan-detail.tsx b/web/ce/components/workspace/billing/comparison/plan-detail.tsx new file mode 100644 index 000000000..313b0e96e --- /dev/null +++ b/web/ce/components/workspace/billing/comparison/plan-detail.tsx @@ -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 = 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 ( +
+ {/* Plan name and pricing section */} +
+
+ {subscriptionName} + {subscriptionType === EProductSubscriptionEnum.PRO && ( + Popular + )} +
+
+ {isSubscriptionActive && displayPrice !== undefined && ( + + {"$" + displayPrice} + + )} +
+ {pricingDescription &&
{pricingDescription}
} + {pricingSecondaryDescription && ( +
+ {pricingSecondaryDescription} +
+ )} +
+
+
+ + {/* Billing frequency toggle */} + {SUBSCRIPTION_WITH_BILLING_FREQUENCY.includes(subscriptionType) && billingFrequency && ( +
+ +
+ )} + + {/* Subscription button */} +
+ +
+
+ ); +}); diff --git a/web/ce/components/workspace/billing/comparison/root.tsx b/web/ce/components/workspace/billing/comparison/root.tsx new file mode 100644 index 000000000..9070dd354 --- /dev/null +++ b/web/ce/components/workspace/billing/comparison/root.tsx @@ -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>; + setIsScrolled: React.Dispatch>; +}; + +export const PlansComparison = observer( + forwardRef(function PlansComparison( + props: TPlansComparisonProps, + ref: React.Ref + ) { + const { + isScrolled, + isCompareAllFeaturesSectionOpen, + getBillingFrequency, + setBillingFrequency, + setIsCompareAllFeaturesSectionOpen, + setIsScrolled, + } = props; + // plan details + const { planDetails } = PLANE_PLANS; + + return ( + { + const currentPlanKey = planKey as TPlanePlans; + if (!shouldRenderPlanDetail(currentPlanKey)) return null; + return ( + setBillingFrequency(plan.id, frequency)} + /> + ); + })} + isSelfManaged + isScrolled={isScrolled} + isCompareAllFeaturesSectionOpen={isCompareAllFeaturesSectionOpen} + setIsCompareAllFeaturesSectionOpen={setIsCompareAllFeaturesSectionOpen} + setIsScrolled={setIsScrolled} + /> + ); + }) +); diff --git a/web/ce/components/workspace/billing/root.tsx b/web/ce/components/workspace/billing/root.tsx index e9c6bd8f7..f76052584 100644 --- a/web/ce/components/workspace/billing/root.tsx +++ b/web/ce/components/workspace/billing/root.tsx @@ -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(null); + const [isCompareAllFeaturesSectionOpen, setIsCompareAllFeaturesSectionOpen] = useState(false); + const [productBillingFrequency, setProductBillingFrequency] = useState( + 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 ( -
+
-
-

{t("workspace_settings.settings.billing_and_plans.title")}

+
+

Billing and plans

-
-
-

{t("workspace_settings.settings.billing_and_plans.current_plan")}

-

- {t("workspace_settings.settings.billing_and_plans.free_plan")} -

- - - + +
+
+
+
+
+

+ Community +

+
+ Unlimited projects, issues, cycles, modules, pages, and storage +
+
+
+
+
All plans
+
); -}; +}); diff --git a/web/ce/components/workspace/edition-badge.tsx b/web/ce/components/workspace/edition-badge.tsx index effb5501b..b32ce9e61 100644 --- a/web/ce/components/workspace/edition-badge.tsx +++ b/web/ce/components/workspace/edition-badge.tsx @@ -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 diff --git a/web/ce/components/workspace/upgrade/index.tsx b/web/ce/components/workspace/upgrade/index.tsx deleted file mode 100644 index 25115ef33..000000000 --- a/web/ce/components/workspace/upgrade/index.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./pro-plan-upgrade"; -export * from "./one-plan-upgrade"; -export * from "./paid-plans-upgrade-modal"; diff --git a/web/ce/components/workspace/upgrade/one-plan-upgrade.tsx b/web/ce/components/workspace/upgrade/one-plan-upgrade.tsx deleted file mode 100644 index 6476d2076..000000000 --- a/web/ce/components/workspace/upgrade/one-plan-upgrade.tsx +++ /dev/null @@ -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 = (props) => { - const { features, verticalFeatureList = false, extraFeatures } = props; - // env - const PLANE_ONE_PAYMENT_URL = "https://prime.plane.so/"; - - return ( -
-
-
-
Plane One
-
$799
-
for two years’ support and updates
-
- -
-
Everything in Free +
-
    - {features.map((feature) => ( -
  • -

    - - {feature} -

    -
  • - ))} -
- {extraFeatures &&
{extraFeatures}
} -
-
- ); -}; diff --git a/web/ce/components/workspace/upgrade/paid-plans-upgrade-modal.tsx b/web/ce/components/workspace/upgrade/paid-plans-upgrade-modal.tsx deleted file mode 100644 index 88201af63..000000000 --- a/web/ce/components/workspace/upgrade/paid-plans-upgrade-modal.tsx +++ /dev/null @@ -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 = (props) => { - const { isOpen, handleClose } = props; - - return ( - -
-
-
-
Upgrade to a paid plan and unlock missing features.
-
-

- 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. -

-
- {/* Free plan details */} -
-
- - Your plan - -
-
-
Free
-
$0 a user per month
-
-
-
    - {FREE_PLAN_UPGRADE_FEATURES.map((feature) => ( -
  • -

    - - {feature} -

    -
  • - ))} -
-
-
-
- - -
-
-
- ); -}; diff --git a/web/ce/components/workspace/upgrade/pro-plan-upgrade.tsx b/web/ce/components/workspace/upgrade/pro-plan-upgrade.tsx deleted file mode 100644 index 814c43095..000000000 --- a/web/ce/components/workspace/upgrade/pro-plan-upgrade.tsx +++ /dev/null @@ -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 = (props) => { - const { basePlan, features, verticalFeatureList = false, extraFeatures } = props; - // states - const [selectedPlan, setSelectedPlan] = useState("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 ( -
- -
- - {PRO_PLAN_PRICES.map((price: TProPlanPrice) => ( - - 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" && ( - - -{yearlyDiscount}% - - )} - - - ))} - -
- - {PRO_PLAN_PRICES.map((price: TProPlanPrice) => ( - -
-
Plane Pro
-
- {price.currency} - {price.price} -
-
a user per month
-
- -
-
{`Everything in ${basePlan} +`}
-
    - {features.map((feature) => ( -
  • -

    - - {feature} -

    -
  • - ))} -
- {extraFeatures &&
{extraFeatures}
} -
-
- ))} -
-
-
- ); -}; diff --git a/web/core/components/license/index.ts b/web/core/components/license/index.ts new file mode 100644 index 000000000..031608e25 --- /dev/null +++ b/web/core/components/license/index.ts @@ -0,0 +1 @@ +export * from "./modal"; diff --git a/web/core/components/license/modal/card/base-paid-plan-card.tsx b/web/core/components/license/modal/card/base-paid-plan-card.tsx new file mode 100644 index 000000000..cfbb44fe6 --- /dev/null +++ b/web/core/components/license/modal/card/base-paid-plan-card.tsx @@ -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 | undefined; + verticalFeatureList?: boolean; + extraFeatures?: string | React.ReactNode; + renderPriceContent: (price: TSubscriptionPrice) => React.ReactNode; + renderActionButton: (price: TSubscriptionPrice) => React.ReactNode; + isSelfHosted: boolean; +}; + +export const BasePaidPlanCard: FC = observer((props) => { + const { + planVariant, + features, + prices, + verticalFeatureList = false, + extraFeatures, + renderPriceContent, + renderActionButton, + isSelfHosted, + } = props; + // states + const [selectedPlan, setSelectedPlan] = useState("month"); + const basePlan = getBaseSubscriptionName(planVariant, isSelfHosted); + const upgradeCardVariantStyle = getUpgradeCardVariantStyle(planVariant); + // Plane details + const planeName = getSubscriptionName(planVariant); + + return ( +
+ +
+ + {prices.map((price: TSubscriptionPrice) => ( + + 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)} + + ))} + +
+ + {prices.map((price: TSubscriptionPrice) => ( + +
+
Plane {planeName}
+ {renderActionButton(price)} +
+
+
{`Everything in ${basePlan} +`}
+
    + {features.map((feature) => ( +
  • +

    + + {feature} +

    +
  • + ))} +
+ {extraFeatures &&
{extraFeatures}
} +
+
+ ))} +
+
+
+ ); +}); diff --git a/web/core/components/license/modal/card/checkout-button.tsx b/web/core/components/license/modal/card/checkout-button.tsx new file mode 100644 index 000000000..9e81585cd --- /dev/null +++ b/web/core/components/license/modal/card/checkout-button.tsx @@ -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 | undefined; + renderTrialButton?: (props: { productId: string | undefined; priceId: string | undefined }) => React.ReactNode; + handleCheckout: (params: TCheckoutParams) => void; + isSelfHosted: boolean; + isTrialAllowed: boolean; +}; + +export const PlanCheckoutButton: FC = 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 ( + <> +
+
+ {isLoading ? ( + + + + ) : ( + + {price.currency} + {price.price} + + )} +
+
+ per user per month +
+
+ {isLoading ? ( + + + + ) : ( +
+ + {isTrialAllowed && !isSelfHosted && ( +
+ {renderTrialButton && + renderTrialButton({ + productId: product?.id, + priceId: price.id, + })} +
+ )} +
+ )} + + ); +}); diff --git a/web/core/components/license/modal/card/free-plan.tsx b/web/core/components/license/modal/card/free-plan.tsx new file mode 100644 index 000000000..946742671 --- /dev/null +++ b/web/core/components/license/modal/card/free-plan.tsx @@ -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 ( +
+ {isOnFreePlan && ( +
+ + Your plan + +
+ )} +
+
Free
+
$0 per user per month
+
+
+
    + {FREE_PLAN_UPGRADE_FEATURES.map((feature) => ( +
  • +

    + + {feature} +

    +
  • + ))} +
+
+
+ ); +}); diff --git a/web/core/components/license/modal/card/index.ts b/web/core/components/license/modal/card/index.ts new file mode 100644 index 000000000..0333c15db --- /dev/null +++ b/web/core/components/license/modal/card/index.ts @@ -0,0 +1,4 @@ +export * from "./base-paid-plan-card"; +export * from "./free-plan"; +export * from "./talk-to-sales"; +export * from "./plan-upgrade"; diff --git a/web/core/components/license/modal/card/plan-upgrade.tsx b/web/core/components/license/modal/card/plan-upgrade.tsx new file mode 100644 index 000000000..fca1a013d --- /dev/null +++ b/web/core/components/license/modal/card/plan-upgrade.tsx @@ -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 | 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 = 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 ( + + ); + } + + const renderPriceContent = (price: TSubscriptionPrice) => ( + <> + {price.recurring === "month" && "Monthly"} + {price.recurring === "year" && ( + <> + Yearly + {yearlyDiscount > 0 && ( + + -{yearlyDiscount}% + + )} + + )} + + ); + + return ( + ( + + )} + isSelfHosted={isSelfHosted} + /> + ); +}); diff --git a/web/core/components/license/modal/card/talk-to-sales.tsx b/web/core/components/license/modal/card/talk-to-sales.tsx new file mode 100644 index 000000000..a136d6596 --- /dev/null +++ b/web/core/components/license/modal/card/talk-to-sales.tsx @@ -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 | undefined; + verticalFeatureList?: boolean; + extraFeatures?: string | React.ReactNode; + isSelfHosted: boolean; + isTrialAllowed: boolean; +}; + +export const TalkToSalesCard: FC = 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 ( + <> +
+
+ {isLoading ? ( + + + + ) : ( + <>Quote on request + )} +
+
per user per month
+
+ {isLoading ? ( + + + + ) : ( +
+ + Talk to Sales + + {isTrialAllowed && !isSelfHosted &&
} +
+ )} + + ); + }; + + return ( + + ); +}); diff --git a/web/core/components/license/modal/index.ts b/web/core/components/license/modal/index.ts new file mode 100644 index 000000000..1d243e763 --- /dev/null +++ b/web/core/components/license/modal/index.ts @@ -0,0 +1 @@ +export * from "./card"; diff --git a/web/core/components/workspace/billing/comparison/base.tsx b/web/core/components/workspace/billing/comparison/base.tsx new file mode 100644 index 000000000..b71ac0479 --- /dev/null +++ b/web/core/components/workspace/billing/comparison/base.tsx @@ -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>; + setIsScrolled: React.Dispatch>; +}; + +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(function PlansComparisonBase( + props: TPlansComparisonBaseProps, + ref: React.Ref + ) { + 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 ( +
+
+
+
+
+ {planeDetails} +
+ {/* Plan Headers */} +
+ {/* Plan Highlights */} +
+
Highlights
+ {Object.entries(planHighlights).map( + ([planKey, highlights]) => + shouldRenderPlanDetail(planKey as TPlanePlans) && ( +
+
    + {highlights.map((highlight, index) => ( +
  • {highlight}
  • + ))} +
+
+ ) + )} +
+
+ + {/* Feature Comparison */} + {isCompareAllFeaturesSectionOpen && ( + <> + {planComparison.map((section, sectionIdx) => ( +
+

+ {section.title} {section.comingSoon && } +

+
+ {section.features.map((feature, featureIdx) => ( +
+
+
+ {feature.title} {feature.comingSoon && } +
+
+ {PLANS_LIST.map( + (planKey) => + shouldRenderPlanDetail(planKey) && ( +
+ +
+ ) + )} +
+ ))} +
+
+ ))} + + )} +
+ + {/* Toggle Button */} +
+ +
+
+
+ ); + }) +); diff --git a/web/core/components/workspace/billing/comparison/feature-detail.tsx b/web/core/components/workspace/billing/comparison/feature-detail.tsx new file mode 100644 index 000000000..90b3d709e --- /dev/null +++ b/web/core/components/workspace/billing/comparison/feature-detail.tsx @@ -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 = (props) => { + const { subscriptionType, data } = props; + + if (data === null || data === undefined) { + return ; + } + if (data === true) { + return ; + } + if (data === false) { + return ; + } + return <>{data}; +}; diff --git a/web/core/components/workspace/billing/comparison/index.ts b/web/core/components/workspace/billing/comparison/index.ts new file mode 100644 index 000000000..43f98b7e8 --- /dev/null +++ b/web/core/components/workspace/billing/comparison/index.ts @@ -0,0 +1,2 @@ +export * from "./base"; +export * from "./feature-detail"; diff --git a/web/core/components/workspace/billing/index.ts b/web/core/components/workspace/billing/index.ts new file mode 100644 index 000000000..a71831671 --- /dev/null +++ b/web/core/components/workspace/billing/index.ts @@ -0,0 +1 @@ +export * from "./comparison"; diff --git a/web/core/components/workspace/billing/subscription.ts b/web/core/components/workspace/billing/subscription.ts new file mode 100644 index 000000000..e895d7c98 --- /dev/null +++ b/web/core/components/workspace/billing/subscription.ts @@ -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"); diff --git a/web/core/components/workspace/index.ts b/web/core/components/workspace/index.ts index ebf6b18d2..81758c485 100644 --- a/web/core/components/workspace/index.ts +++ b/web/core/components/workspace/index.ts @@ -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"; diff --git a/web/core/constants/plans.tsx b/web/core/constants/plans.tsx new file mode 100644 index 000000000..9afc97018 --- /dev/null +++ b/web/core/constants/plans.tsx @@ -0,0 +1,1304 @@ +import { Mail, MessageCircle } from "lucide-react"; +// plane imports +import { EProductSubscriptionEnum } from "@plane/constants"; +import { DiscordIcon } from "@plane/ui"; +import { cn } from "@plane/utils"; + +export type TPlanFeatureData = React.ReactNode | boolean | null; + +// TODO: we should change this type and use TProductSubscriptionType instead. Need changes in common constants. +export type TPlanePlans = "free" | "one" | "pro" | "business" | "enterprise"; + +export type TPlanDetail = { + id: EProductSubscriptionEnum; + name: React.ReactNode; + monthlyPrice?: number; + yearlyPrice?: number; + monthlyPriceSecondaryDescription?: React.ReactNode; + yearlyPriceSecondaryDescription?: React.ReactNode; + buttonCTA?: React.ReactNode; + isActive: boolean; +}; + +type TPlanFeatureDetails = { + title: React.ReactNode; + description?: React.ReactNode; + selfHostedDescription?: React.ReactNode; + comingSoon?: boolean; + selfHostedOnly?: boolean; + cloud: Record; + "self-hosted"?: Record; +}; + +type TPlansComparisonDetails = { + id: string; + title: React.ReactNode; + comingSoon?: boolean; + cloudOnly?: boolean; + selfHostedOnly?: boolean; + features: TPlanFeatureDetails[]; +}; + +type PlanePlans = { + planDetails: Record; + planHighlights: Record; + planComparison: TPlansComparisonDetails[]; +}; + +const RiDiscordFill = ({ className }: { className?: string }) => ( + +); + +export const ComingSoonBadge = ({ className }: { className?: string }) => ( + + COMING SOON + +); + +export const PLANS_LIST: TPlanePlans[] = ["free", "one", "pro", "business", "enterprise"]; + +export const PLANS_COMPARISON_LIST: TPlansComparisonDetails[] = [ + { + id: "project-work-tracking", + title: "Project + work tracking", + features: [ + { + title: "Projects", + description: "Add projects to house work items, cycles, and modules.", + cloud: { + free: true, + one: true, + pro: true, + business: true, + enterprise: true, + }, + }, + { + title: "Work items", + description: "Add work via work items, set properties for tracking, and add to\ncycles or modules.", + cloud: { + free: true, + one: true, + pro: true, + business: true, + enterprise: true, + }, + }, + { + title: "Comments", + description: "Respond to work items, @mention members, and brainstorm\ntogether without leaving Plane.", + cloud: { + free: true, + one: true, + pro: true, + business: true, + enterprise: true, + }, + }, + { + title: "Cycles", + description: "Track work in timeboxes with differing frequency.", + cloud: { + free: true, + one: true, + pro: true, + business: true, + enterprise: true, + }, + }, + { + title: "Modules", + description: "Group replicable work in modules with their own\nleads.", + cloud: { + free: true, + one: true, + pro: true, + business: true, + enterprise: true, + }, + }, + { + title: "Intake", + description: + "See suggestions and feedback from viewers and\nguests before you decide to add them to your\nproject.", + cloud: { + free: true, + one: true, + pro: true, + business: true, + enterprise: true, + }, + }, + { + title: "Estimates", + description: "Measure effort in points in a system that works for\nyou.", + cloud: { + free: "Basic", + one: "Basic", + pro: "Advanced", + business: "Advanced", + enterprise: "Advanced", + }, + }, + ], + }, + { + id: "project-work-management", + title: "Project + work management", + features: [ + { + title: "Bulk Ops", + description: "Add several work items to cycles or modules, transfer\nthem, or edit their properties.", + cloud: { + free: false, + one: "Limited props", + pro: "All props", + business: ( + + + Work item transfers and conversions + + ), + enterprise: ( + + + Work item transfers and conversions + + ), + }, + }, + { + title: "Time Tracking + Worklogs", + description: "Track time per work item, see aggregated reports, and\nfilter by need.", + cloud: { + free: false, + one: "Basic", + pro: "Historical timesheets", + business: "Historical timesheets\nand approvals", + enterprise: "Historical timesheets\nand approvals", + }, + }, + { + title: "Active Cycles", + description: "See all running cycles across all projects, or soon, in\na single project.", + cloud: { + free: false, + one: true, + pro: true, + business: true, + enterprise: true, + }, + }, + { + title: "Work item Types", + description: "Create your own work item types with your own\nproperties.", + cloud: { + free: false, + one: false, + pro: true, + business: true, + enterprise: true, + }, + }, + { + title: "Custom Properties", + description: "Create your own properties and apply them to your\nworkspace or project.", + cloud: { + free: false, + one: false, + pro: "Project-level\ncustom properties", + business: "Workspace-level\nproperties and roll-ups", + enterprise: "Workspace-level\nproperties and roll-ups", + }, + }, + { + title: "Dependencies in Gantt", + description: "Adjust timelines for dependent work items visually on\nour Gantt layout.", + cloud: { + free: false, + one: false, + pro: true, + business: true, + enterprise: true, + }, + }, + { + title: "Work item Transfers", + description: "Move a work item from a project or a cycle to\nanother.", + cloud: { + free: false, + one: false, + pro: true, + business: true, + enterprise: true, + }, + }, + { + title: "Auto-transfer Cycle Work items", + description: + "Transfer incomplete work items from a completed cycle\nto the next cycle or to the default project state. ", + cloud: { + free: false, + one: false, + pro: true, + business: true, + enterprise: true, + }, + }, + { + title: "Epics", + description: "Organize long-term work in epics that house work items,\ncycles, and modules.", + cloud: { + free: false, + one: false, + pro: true, + business: true, + enterprise: true, + }, + }, + { + title: "Initiatives", + description: "Create initiatives to roll up several epics.", + comingSoon: true, + cloud: { + free: false, + one: false, + pro: true, + business: true, + enterprise: true, + }, + }, + { + title: "Checkpoints", + description: + "Add markers to Projects, Epics and Initiatives to keep your\nteam on track and report on progress.", + comingSoon: true, + cloud: { + free: false, + one: false, + pro: true, + business: true, + enterprise: true, + }, + }, + { + title: "Module Overview", + description: "Like Cycle Overviews, see relevant details and\nprogress charts for each module.", + cloud: { + free: false, + one: false, + pro: true, + business: true, + enterprise: true, + }, + }, + { + title: "Auto-assignment In Modules", + description: "Choose assignment rules for work items in a\nmodule including Linear, Round Robin, or Capacity.", + cloud: { + free: false, + one: false, + pro: "Linear", + business: "Round-robin and Capacity", + enterprise: "Round-robin and Capacity", + }, + }, + // { + // title: "Project Overview", + // description: "See just-in-time snapshots of your project with\nessential metrics.", + // comingSoon: true, + // cloud: { + // free: false, + // one: false, + // pro: true, + // business: true, + // enterprise: true, + // }, + // }, + { + title: "Public, Private, and Secret projects", + description: + "Public projects are visible and accessible to\neveryone. Private ones are visible but need approval\nto join. Secret projects aren't visible or accessible.", + cloud: { + free: false, + one: false, + pro: true, + business: true, + enterprise: true, + }, + }, + { + title: "State Of Projects", + description: + "See all projects laid across states that highlight\nthose that need attention and those on track.", + cloud: { + free: false, + one: false, + pro: true, + business: true, + enterprise: true, + }, + }, + // { + // title: "Project Updates", + // description: + // "Keep stakeholders in the loop with a dedicated\nspace for updates that everyone in the project can\nsee.", + // comingSoon: true, + // cloud: { + // free: false, + // one: false, + // pro: true, + // business: true, + // enterprise: true, + // }, + // }, + { + title: "Pre-defined work item Templates", + description: + "Choose from our available work item templates that\ncustomize work item types and properties for several\nuse cases.", + comingSoon: true, + cloud: { + free: false, + one: false, + pro: true, + business: true, + enterprise: true, + }, + }, + { + title: "Teamspace Cycles", + description: "See multiple cycles in multiple projects at once.", + cloud: { + free: false, + one: false, + pro: false, + business: true, + enterprise: true, + }, + }, + { + title: "Project Templates", + description: "Save states, workflows, automation, and other project\nsettings into templates.", + cloud: { + free: false, + one: false, + pro: false, + business: true, + enterprise: true, + }, + }, + { + title: "Baselines And Deviations", + description: "Declare baselines for how your projects progress\nand zoom in on deviations.", + cloud: { + free: false, + one: false, + pro: false, + business: true, + enterprise: true, + }, + }, + { + title: "Scheduled Comms", + description: "Schedule reports, notifications, and messages to\nthird-party tools.", + cloud: { + free: false, + one: false, + pro: false, + business: true, + enterprise: true, + }, + }, + { + title: "Intake Assignees", + description: "Assign approved Intake work items to a member by\ndefault.", + cloud: { + free: false, + one: false, + pro: false, + business: true, + enterprise: true, + }, + }, + { + title: "Custom SLAs", + description: "Set SLA matrices for time-sensitive work items.", + cloud: { + free: false, + one: false, + pro: false, + business: true, + enterprise: true, + }, + }, + { + title: "Intake Forms", + description: "Take Intake work items from externally accessible web\nforms.", + cloud: { + free: false, + one: false, + pro: false, + business: true, + enterprise: true, + }, + }, + { + title: "Emails For Intake", + description: "Get an email address for reporting work items\ndirectly into a project's Intake.", + comingSoon: true, + cloud: { + free: false, + one: false, + pro: false, + business: true, + enterprise: true, + }, + }, + ], + }, + { + id: "visualization", + title: "Visualization", + features: [ + { + title: "Layouts", + description: + "Choose from the List, the Board, the Calendar, the\nGantt, or the Spreadsheet layout for your work items.", + cloud: { + free: true, + one: true, + pro: true, + business: true, + enterprise: true, + }, + }, + { + title: "Views", + description: "Save sort, filter, and display options on a layout to a\nview.", + cloud: { + free: true, + one: true, + pro: true, + business: true, + enterprise: true, + }, + }, + { + title: "Shared Views", + description: "Choose a few members to share a view with.", + cloud: { + free: false, + one: false, + pro: true, + business: true, + enterprise: true, + }, + }, + { + title: "Publish Views", + description: "Put a view on the Internet and let your customers\ninteract with them.", + cloud: { + free: false, + one: false, + pro: true, + business: true, + enterprise: true, + }, + }, + { + title: "Dashboards and Widgets", + description: "Create your own dashboards with custom widgets\nand data types.", + cloud: { + free: false, + one: false, + pro: true, + business: true, + enterprise: true, + }, + }, + ], + }, + { + id: "analytics-reports", + title: "Analytics + reports", + features: [ + { + title: "Progress Charts", + description: + "Track progress in cycles, modules, and overviews\nthroughout Plane without switching to dashboards\nor Analytics.", + cloud: { + free: false, + one: false, + pro: true, + business: true, + enterprise: true, + }, + }, + { + title: "Cycle Reports", + description: "Get on-demand cycle reports during and after a\ncycle. Revisit reports anytime from permalinks.", + cloud: { + free: false, + one: false, + pro: true, + business: true, + enterprise: true, + }, + }, + { + title: "Insights", + description: "Hindsight, On-demand insights, Foresights.", + comingSoon: true, + cloud: { + free: false, + one: false, + pro: true, + business: true, + enterprise: true, + }, + }, + // { + // title: "Time Capsule", + // description: "Go back in your project's timeline and see point-in-\ntime snapshots.", + // comingSoon: true, + // cloud: { + // free: false, + // one: false, + // pro: false, + // business: true, + // enterprise: true, + // }, + // }, + { + title: "Advanced Pages Analytics", + description: "See who's viewing, sharing, and commenting on\nyour pages along with other useful info.", + comingSoon: true, + cloud: { + free: false, + one: false, + pro: false, + business: true, + enterprise: true, + }, + }, + { + title: "Custom Reports", + description: "Generate reports by any dimension and metric\nacross your project or workspace.", + comingSoon: true, + cloud: { + free: false, + one: false, + pro: false, + business: true, + enterprise: true, + }, + }, + ], + }, + { + id: "navigation", + title: "Navigation", + features: [ + { + title: "Power K", + description: "Access a keyboard-first gateway to almost anything\nin Plane.", + cloud: { + free: true, + one: true, + pro: true, + business: true, + enterprise: true, + }, + }, + // { + // title: "Search", + // description: "Search via natural-language queries, operators, or\nPQL", + // cloud: { + // free: "Basic text search", + // one: "Basic text search", + // pro: ( + // + // + // COMING SOON + // + // Operator capsules from text or PQL + // + // ), + // business: ( + // + // + // COMING SOON + // + // Operator capsules from text or PQL + // + // ), + // enterprise: ( + // + // + // COMING SOON + // + // Operator capsules from text or PQL + // + // ), + // }, + // }, + { + title: "PQL", + description: + "Write Plane Query Language in search with support\nfor Boolean operators. Soon, you can write natural\nlanguage queries.", + cloud: { + free: false, + one: false, + pro: true, + business: true, + enterprise: true, + }, + }, + ], + }, + { + id: "workspace-user-management", + title: "Workspace and user management", + features: [ + { + title: "Member limit", + description: "Number of seats that can use project and work management features", + selfHostedDescription: "Number of users that our standard infra supports\nIncrease infra to get more users", + cloud: { + free: "12", + one: "", + pro: "Unlimited", + business: "Unlimited", + enterprise: "Unlimited", + }, + "self-hosted": { + free: "~50", + one: "~50", + pro: "~200", + business: "~200", + enterprise: "Unlimited", + }, + }, + { + title: "Roles", + description: "Choose from one of four pre-defined roles or create\ncustom ones with RBAC.", + cloud: { + free: "Basic", + one: "Basic", + pro: "Pre-defined roles", + business: "RBAC", + enterprise: "GAC", + }, + }, + { + title: "Guests", + description: "Let some users see everything or just their work items in\na project.", + cloud: { + free: false, + one: "5 per paid member", + pro: "5 per paid member", + business: "5 per paid member", + enterprise: "5 per paid member", + }, + }, + { + title: "Approvals", + description: "Set workspace, project, and work item type approvals to\ndesignated admins.", + comingSoon: true, + cloud: { + free: false, + one: false, + pro: false, + business: true, + enterprise: true, + }, + }, + { + title: "Admin Interface", + description: "Get an admin overview to manage workspace and\nproject settings.", + cloud: { + free: false, + one: false, + pro: false, + business: true, + enterprise: true, + }, + }, + { + title: "Workspace Activity Logs", + description: "See filterable activity logs for your entire\nworkspace.", + cloud: { + free: false, + one: false, + pro: false, + business: true, + enterprise: true, + }, + }, + { + title: "API-enabled Audit Logs", + description: "See a full-workspace audit log and use APIs to flag\nPlane activity in compliance systems.", + comingSoon: true, + cloud: { + free: false, + one: false, + pro: false, + business: true, + enterprise: true, + }, + }, + ], + }, + { + id: "automations-workflows", + title: "Automations and workflows", + features: [ + { + title: "Trigger And Action", + description: "Choose a trigger and a corresponding action per\nautomation flow.", + cloud: { + free: false, + one: false, + pro: true, + business: true, + enterprise: true, + }, + }, + { + title: "Decisions And Loops Automation", + description: "Use actions as triggers indefinitely in an\nautomation flow.", + comingSoon: true, + cloud: { + free: false, + one: false, + pro: false, + business: true, + enterprise: true, + }, + }, + { + title: "Number of automations", + description: "Total number of automation flows in your\nworkspace", + cloud: { + free: false, + one: false, + pro: "5,000", + business: "10,000", + enterprise: "Unlimited", + }, + }, + ], + }, + { + id: "knowledge-management", + title: "Knowledge management", + features: [ + { + title: "Pages", + description: "Build knowledge bases for your teams which are\naccessible & shareable.", + cloud: { + free: true, + one: true, + pro: true, + business: true, + enterprise: true, + }, + }, + { + title: "Real-time Collab", + description: "Edit a page together with members in your project,\nteam, or workspace.", + cloud: { + free: false, + one: true, + pro: true, + business: true, + enterprise: true, + }, + }, + { + title: "Work item Embeds", + description: "Embed work items from any project you are a member\nof.", + cloud: { + free: false, + one: true, + pro: true, + business: true, + enterprise: true, + }, + }, + { + title: "Link-to-work items", + description: "Link pages in work items in a separate section in work item\ndetails.", + cloud: { + free: false, + one: true, + pro: true, + business: true, + enterprise: true, + }, + }, + { + title: "Publish", + description: + "Put your pages on the web for external users and let\nthem comment without signing into your workspace.", + cloud: { + free: false, + one: true, + pro: true, + business: true, + enterprise: true, + }, + }, + { + title: "Wiki", + description: "Create company-wide wikis or knowledge bases\nwithout creating a project.", + cloud: { + free: false, + one: true, + pro: true, + business: true, + enterprise: true, + }, + }, + { + title: "Exports", + description: "Export page content into PDFs or Word-compatible\ndocs.", + cloud: { + free: false, + one: false, + pro: "One download\nat a time", + business: "Queued downloads", + enterprise: "Queued downloads", + }, + }, + { + title: "Templates", + description: "Use pages as templates for your project, team, or\nworkspace.", + cloud: { + free: false, + one: false, + pro: true, + business: true, + enterprise: true, + }, + }, + { + title: "Versions", + description: "See restorable version of edits to your pages.", + cloud: { + free: false, + one: false, + pro: "2 days", + business: "3 months", + enterprise: "Unlimited", + }, + }, + { + title: "Databases + Formulas", + description: + "Put databases and formulas into a page without\nworrying about losing text, images, or other content\ntypes.", + comingSoon: true, + cloud: { + free: false, + one: false, + pro: false, + business: true, + enterprise: true, + }, + }, + { + title: "Nested Pages", + description: "Pages inside a page, organize your pages\nas you see fit for the progressive\ndisclosure.", + comingSoon: true, + cloud: { + free: false, + one: false, + pro: false, + business: "Word-compatible + other format downloads", + enterprise: "Word-compatible + other format downloads", + }, + }, + ], + }, + { + id: "importers", + title: "Importers", + features: [ + { + title: "Jira", + description: "Import your work items and members from Jira.", + cloud: { + free: "Without custom props", + one: "Without custom props", + pro: "With custom props", + business: "With custom props", + enterprise: "With custom props", + }, + }, + { + title: "GitHub", + description: "Import your work items and members from GitHub.", + cloud: { + free: "Without custom props", + one: "Without custom props", + pro: "With custom props", + business: "With custom props", + enterprise: "With custom props", + }, + }, + ], + }, + { + id: "integrations", + title: "Integrations", + comingSoon: true, + features: [ + { + title: "GitHub", + description: + "Sync Plane work items and states to GitHub work items and\nstates. Update GitHub automatically with activity\nfrom Plane and vice-versa.", + cloud: { + free: false, + one: false, + pro: true, + business: true, + enterprise: true, + }, + }, + { + title: "Slack", + description: "Get Plane activity in Slack and use / commands in\nSlack to make changes in Plane.", + cloud: { + free: false, + one: false, + pro: true, + business: true, + enterprise: true, + }, + }, + { + title: "Zapier", + description: "Run if-then-else automations using Zapier.", + cloud: { + free: false, + one: false, + pro: true, + business: true, + enterprise: true, + }, + }, + { + title: "Zendesk", + description: "Create Plane work items from Zendesk tickets.", + cloud: { + free: false, + one: false, + pro: true, + business: true, + enterprise: true, + }, + }, + { + title: "Freshdesk", + description: "Create Plane work items from Freshdesk tickets.", + cloud: { + free: false, + one: false, + pro: true, + business: true, + enterprise: true, + }, + }, + ], + }, + { + id: "storage", + title: "Storage", + cloudOnly: true, + features: [ + { + title: "Space", + description: "Total storage allowed per workspace", + cloud: { + free: "5GB", + one: false, + pro: "1 TB", + business: "5 TB", + enterprise: "Custom", + }, + }, + { + title: "Max file size", + description: "Limit for uploads to your workspace", + cloud: { + free: "5 MB", + one: false, + pro: "100 MB", + business: "200 MB", + enterprise: "Custom", + }, + }, + ], + }, + { + id: "security", + title: "Security", + features: [ + { + title: "SAML", + description: "Get the officially supported SAML implementation\nand make Plane secure with any IdP.", + cloud: { + free: false, + one: true, + pro: true, + business: true, + enterprise: true, + }, + }, + { + title: "OIDC", + description: "Get the officially supported OIDC implementation\nand make Plane secure with any IdP.", + selfHostedOnly: true, + cloud: { + free: false, + one: true, + pro: true, + business: true, + enterprise: true, + }, + }, + { + title: "Domain Security", + description: + "Choose other domains that can authenticate into\nyour Plane workspace or restrict all but one domain.", + cloud: { + free: false, + one: false, + pro: true, + business: true, + enterprise: true, + }, + }, + { + title: "Two-factor authentication and passkeys", + description: "Secure your Plane workspace with device-\ndependent two-factor authentication and passkeys. ", + cloud: { + free: false, + one: false, + pro: true, + business: true, + enterprise: true, + }, + }, + { + title: "Password Policy", + description: "Set custom password policies in line with your\ncompliance requirements.", + cloud: { + free: false, + one: false, + pro: true, + business: true, + enterprise: true, + }, + }, + { + title: "LDAP", + description: "Get our official LDAP implementation and secure\nyour Plane workspace with your LDAP server.", + comingSoon: true, + cloud: { + free: false, + one: false, + pro: false, + business: false, + enterprise: true, + }, + }, + ], + }, + { + id: "self-hosted", + title: "Self-hosted", + selfHostedOnly: true, + features: [ + { + title: "God Mode", + description: "Manage your self-hosted Plane instance better with\nan instance admin interface.", + cloud: { + free: true, + one: true, + pro: true, + business: true, + enterprise: true, + }, + }, + { + title: "One-click Deployment", + description: "Install and deploy your self-hosted Plane to any\nprivate cloud with a single-line command.", + cloud: { + free: false, + one: true, + pro: true, + business: true, + enterprise: true, + }, + }, + { + title: "Digital Ocean Marketplace app", + description: "Get our Digital Ocean-compatible app on their\nmarketplace.", + cloud: { + free: false, + one: true, + pro: true, + business: true, + enterprise: true, + }, + }, + { + title: "Heroku Platform app", + description: "Get our Heroku Platform-compatible app and deploy\nto Heroku easily.", + cloud: { + free: false, + one: true, + pro: true, + business: true, + enterprise: true, + }, + }, + { + title: "AWS AMI", + description: "Get our AMI-compatible app from the AWS\nmarketplace.", + cloud: { + free: false, + one: true, + pro: true, + business: true, + enterprise: true, + }, + }, + { + title: "Private deployments", + description: "Get our hosted Cloud app on a private Cloud\nmanaged by us.", + comingSoon: true, + cloud: { + free: false, + one: false, + pro: false, + business: false, + enterprise: true, + }, + }, + ], + }, + { + id: "support", + title: "Support", + features: [ + { + title: "Channels", + description: "Get access to one or more Support channels\nby your plan.", + cloud: { + free: ( + <> + + + ), + one: ( +
+ + +
+ ), + pro: ( +
+ + + +
+ ), + business: "Full-suite\nprofessional services", + enterprise: "Full-suite\nprofessional services", + }, + }, + { + title: "SLA", + description: ( + <> + Get business-friendly SLAs with higher plans. SLAs are by priority of work item and tiers{" "} + + can be requested + + . + + ), + cloud: { + free: false, + one: false, + pro: true, + business: true, + enterprise: true, + }, + }, + ], + }, +]; + +export const PLANE_PLANS: PlanePlans = { + planDetails: { + free: { + id: EProductSubscriptionEnum.FREE, + name: "Free", + monthlyPrice: 0, + yearlyPrice: 0, + isActive: true, + }, + one: { + id: EProductSubscriptionEnum.ONE, + name: "One", + monthlyPrice: 799, + yearlyPrice: 799, + monthlyPriceSecondaryDescription: "per workspace", + yearlyPriceSecondaryDescription: "per workspace", + buttonCTA: "Upgrade", + isActive: false, + }, + pro: { + id: EProductSubscriptionEnum.PRO, + name: "Pro", + monthlyPrice: 8, + yearlyPrice: 6, + monthlyPriceSecondaryDescription: "billed monthly", + yearlyPriceSecondaryDescription: "billed yearly", + buttonCTA: "Upgrade", + isActive: true, + }, + business: { + id: EProductSubscriptionEnum.BUSINESS, + name: "Business", + monthlyPriceSecondaryDescription: "billed monthly", + yearlyPriceSecondaryDescription: "billed yearly", + buttonCTA: "Talk to Sales", + isActive: false, + }, + enterprise: { + id: EProductSubscriptionEnum.ENTERPRISE, + name: "Enterprise", + monthlyPriceSecondaryDescription: "billed monthly", + yearlyPriceSecondaryDescription: "billed yearly", + buttonCTA: "Talk to Sales", + isActive: false, + }, + }, + planHighlights: { + free: ["Upto 12 users", "Pages", "Unlimited projects", "Unlimited cycles and modules"], + one: ["Upto 50 users", "OIDC and SAML", "Active cycles", "Limited time tracking"], + pro: ["Unlimited users", "Custom work items + Properties", "Work item templates", "Full Time Tracking"], + business: ["RBAC", "Project Templates", "Baselines And Deviations", "Custom Reports"], + enterprise: ["Private + managed deployments", "GAC", "LDAP support", "Databases + Formulas"], + }, + planComparison: PLANS_COMPARISON_LIST, +}; diff --git a/web/styles/globals.css b/web/styles/globals.css index 7d44ad0d1..68e81e098 100644 --- a/web/styles/globals.css +++ b/web/styles/globals.css @@ -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;