feat: init
Some checks failed
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled

This commit is contained in:
chuan
2025-11-11 01:56:44 +08:00
commit bba4bb40c8
4638 changed files with 447437 additions and 0 deletions

View File

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

View File

@@ -0,0 +1,99 @@
"use client";
import type { FC } from "react";
import { useState } from "react";
import { observer } from "mobx-react";
import { CheckCircle } from "lucide-react";
import { Tab } from "@headlessui/react";
// plane imports
// helpers
import type { EProductSubscriptionEnum, TBillingFrequency, TSubscriptionPrice } from "@plane/types";
import { getSubscriptionBackgroundColor, getUpgradeCardVariantStyle } from "@plane/ui";
import { cn, getBaseSubscriptionName, getSubscriptionName } from "@plane/utils";
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;
};
export const BasePaidPlanCard: FC<TBasePaidPlanCardProps> = observer((props) => {
const {
planVariant,
features,
prices,
verticalFeatureList = false,
extraFeatures,
renderPriceContent,
renderActionButton,
} = props;
// states
const [selectedPlan, setSelectedPlan] = useState<TBillingFrequency>("month");
const basePlan = getBaseSubscriptionName(planVariant);
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,110 @@
"use client";
import type { FC } from "react";
import { observer } from "mobx-react";
// plane imports
import { getButtonStyling } from "@plane/propel/button";
import type { EProductSubscriptionEnum, IPaymentProduct, TSubscriptionPrice } from "@plane/types";
import { getUpgradeButtonStyle, Loader } from "@plane/ui";
import { cn } from "@plane/utils";
// local imports
import { DiscountInfo } from "./discount-info";
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">
<DiscountInfo
currency={price.currency}
frequency={price.recurring}
price={price.price}
subscriptionType={planVariant}
className="mr-1.5"
/>
</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,61 @@
import { useTheme } from "next-themes";
// plane imports
import type { TBillingFrequency } from "@plane/types";
import { EProductSubscriptionEnum } from "@plane/types";
import { cn } from "@plane/utils";
type TDiscountInfoProps = {
className?: string;
currency: string;
frequency: TBillingFrequency;
price: number;
subscriptionType: EProductSubscriptionEnum;
};
const PLANS_WITH_DISCOUNT = [EProductSubscriptionEnum.PRO];
const getActualPrice = (frequency: TBillingFrequency, subscriptionType: EProductSubscriptionEnum): number | null => {
switch (subscriptionType) {
case EProductSubscriptionEnum.PRO:
return frequency === "month" ? 10 : 8;
default:
return null;
}
};
export const DiscountInfo = ({ className, currency, frequency, price, subscriptionType }: TDiscountInfoProps) => {
const { resolvedTheme } = useTheme();
// derived values
const actualPrice = getActualPrice(frequency, subscriptionType);
if (!PLANS_WITH_DISCOUNT.includes(subscriptionType)) {
return (
<>
{currency}
{price}
</>
);
}
return (
<>
{actualPrice != price && (
<span className={cn("relative", className)}>
<img
src={
resolvedTheme === "dark"
? "https://images.plane.so/pricing/hero/scribble-white.svg"
: "https://images.plane.so/pricing/hero/scribble-black.svg"
}
alt="image"
className="absolute top-1/2 left-1/2 -translate-y-1/2 -translate-x-1/2 w-full scale-x-125"
/>
{currency}
{actualPrice}
</span>
)}
{currency}
{price}
</>
);
};

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 "@plane/utils";
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,113 @@
"use client";
import type { FC } from "react";
import { observer } from "mobx-react";
// plane imports
import { TALK_TO_SALES_URL } from "@plane/constants";
import type { EProductSubscriptionEnum, IPaymentProduct, TSubscriptionPrice } from "@plane/types";
import { getDiscountPillStyle } from "@plane/ui";
import { calculateYearlyDiscount, cn, getSubscriptionName, getSubscriptionPriceDetails } from "@plane/utils";
// components
import { BasePaidPlanCard, TalkToSalesCard } from "@/components/license";
// local components
import type { TCheckoutParams } from "./checkout-button";
import { PlanCheckoutButton } 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}
product={product}
prices={prices}
upgradeLoaderType={upgradeLoaderType}
verticalFeatureList={verticalFeatureList}
extraFeatures={extraFeatures}
isSelfHosted={isSelfHosted}
isTrialAllowed={isTrialAllowed}
renderTrialButton={renderTrialButton}
/>
);
}
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}
/>
)}
/>
);
});

View File

@@ -0,0 +1,113 @@
"use client";
import type { FC } from "react";
import { observer } from "mobx-react";
// types
// plane imports
import { getButtonStyling } from "@plane/propel/button";
import type { EProductSubscriptionEnum, IPaymentProduct, TSubscriptionPrice } from "@plane/types";
import { getUpgradeButtonStyle, Loader } from "@plane/ui";
import { cn } from "@plane/utils";
// local imports
import { BasePaidPlanCard } from "./base-paid-plan-card";
export type TalkToSalesCardProps = {
planVariant: EProductSubscriptionEnum;
href: string;
isLoading?: boolean;
features: string[];
product: IPaymentProduct | undefined;
prices: TSubscriptionPrice[];
upgradeLoaderType: Omit<EProductSubscriptionEnum, "FREE"> | undefined;
verticalFeatureList?: boolean;
extraFeatures?: string | React.ReactNode;
isSelfHosted: boolean;
isTrialAllowed: boolean;
renderTrialButton?: (props: { productId: string | undefined; priceId: string | undefined }) => React.ReactNode;
};
export const TalkToSalesCard: FC<TalkToSalesCardProps> = observer((props) => {
const {
planVariant,
href,
features,
product,
prices,
isLoading,
verticalFeatureList = false,
extraFeatures,
upgradeLoaderType,
isSelfHosted,
isTrialAllowed,
renderTrialButton,
} = props;
const renderPriceContent = (price: TSubscriptionPrice) => (
<>
{price.recurring === "month" && "Monthly"}
{price.recurring === "year" && "Yearly"}
</>
);
const renderActionButton = (price: TSubscriptionPrice) => {
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 transition-all duration-300 animate-fade-in">
{renderTrialButton &&
renderTrialButton({
productId: product?.id,
priceId: price.id,
})}
</div>
)}
</div>
)}
</>
);
};
return (
<BasePaidPlanCard
planVariant={planVariant}
features={features}
prices={prices}
upgradeLoaderType={upgradeLoaderType}
verticalFeatureList={verticalFeatureList}
extraFeatures={extraFeatures}
renderPriceContent={renderPriceContent}
renderActionButton={renderActionButton}
/>
);
});

View File

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