feat(billing): unlock Enterprise plan and all workspace features
All checks were successful
Deploy Plane Project Tracker / Deploy to cisystem (push) Successful in 31s

This commit is contained in:
vickytechkey 2026-09-13 16:10:56 +05:30
parent 283386386c
commit ed2e1134d1
10 changed files with 148 additions and 91 deletions

View file

@ -8,7 +8,6 @@ import { observer } from "mobx-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { setPromiseToast } from "@plane/propel/toast";
import { Tooltip } from "@makeplane/propel/components/tooltip";
import type { IProject } from "@plane/types";
import { CyclesOutline, IntakeOutline, ModuleOutline, PagesOutline, ViewsOutline } from "@makeplane/propel/icons";
// components
@ -16,8 +15,6 @@ import { SettingsBoxedControlItem } from "@/components/settings/boxed-control-it
import { SettingsHeading } from "@/components/settings/heading";
// hooks
import { useProject } from "@/hooks/store/use-project";
// plane web imports
import { UpgradeBadge } from "@/components/workspace/upgrade-badge";
// local imports
import { ProjectFeatureToggle } from "./helper";
@ -116,16 +113,7 @@ export const ProjectFeaturesList = observer(function ProjectFeaturesList(props:
{Object.entries(PROJECT_FEATURES_LIST).map(([featureItemKey, featureItem]) => (
<div key={featureItemKey}>
<SettingsBoxedControlItem
title={
<span className="flex items-center gap-2">
{t(featureItem.key)}
{featureItem.isPro && (
<Tooltip label="Pro feature">
<UpgradeBadge className="rounded-sm" />
</Tooltip>
)}
</span>
}
title={t(featureItem.key)}
description={t(`${featureItem.key}_description`)}
control={
<ProjectFeatureToggle

View file

@ -66,8 +66,8 @@ export const PlansComparisonBase = observer(function PlansComparisonBase(props:
shouldRenderPlanDetail(planKey as TPlanePlans) && (
<div key={planKey} className="col-span-1 p-3">
<ul className="list-disc space-y-1 text-body-xs-regular">
{highlights.map((highlight, index) => (
<li key={index}>{highlight}</li>
{highlights.map((highlight) => (
<li key={highlight}>{highlight}</li>
))}
</ul>
</div>
@ -79,15 +79,15 @@ export const PlansComparisonBase = observer(function PlansComparisonBase(props:
{/* Feature Comparison */}
{isCompareAllFeaturesSectionOpen && (
<>
{planComparison.map((section, sectionIdx) => (
<section key={sectionIdx} className="flex-shrink-0">
{planComparison.map((section) => (
<section key={section.id} className="flex-shrink-0">
<h2 className="mb-2 flex items-start gap-2 pl-2 text-h5-semibold text-secondary">
{section.title} {section.comingSoon && <ComingSoonBadge />}
</h2>
<div className="border-t border-subtle">
{section.features.map((feature, featureIdx) => (
{section.features.map((feature) => (
<div
key={featureIdx}
key={String(feature.title)}
className="text-caption-md grid gap-3 rounded-xs bg-layer-transparent text-secondary even:bg-layer-1"
style={{ gridTemplateColumns: `repeat(${numberOfPlansToRender + 1}, minmax(0, 1fr))` }}
>

View file

@ -6,12 +6,7 @@
import { observer } from "mobx-react";
// plane imports
import {
SUBSCRIPTION_REDIRECTION_URLS,
SUBSCRIPTION_WITH_BILLING_FREQUENCY,
TALK_TO_SALES_URL,
} from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { SUBSCRIPTION_WITH_BILLING_FREQUENCY } from "@plane/constants";
import { Button } from "@plane/propel/button";
import type { TBillingFrequency } from "@plane/types";
import { EProductSubscriptionEnum } from "@plane/types";
@ -31,27 +26,23 @@ type TPlanDetailProps = {
export const PlanDetail = observer(function PlanDetail(props: TPlanDetailProps) {
const { subscriptionType, planDetail, billingFrequency, setBillingFrequency } = props;
// plane hooks
const { t } = useTranslation();
// subscription details
const subscriptionName = getSubscriptionName(subscriptionType);
const isSubscriptionActive = planDetail.isActive;
// pricing details
const isEnterprise = subscriptionType === EProductSubscriptionEnum.ENTERPRISE;
const displayPrice = billingFrequency === "month" ? planDetail.monthlyPrice : planDetail.yearlyPrice;
const pricingDescription = isSubscriptionActive ? "a user per month" : "Quote on request";
const pricingSecondaryDescription =
billingFrequency === "month"
const pricingDescription = isEnterprise
? "Active Lifetime License"
: isSubscriptionActive
? "a user per month"
: "Quote on request";
const pricingSecondaryDescription = isEnterprise
? "All features unlocked"
: billingFrequency === "month"
? planDetail.monthlyPriceSecondaryDescription
: planDetail.yearlyPriceSecondaryDescription;
const handleRedirection = () => {
const frequency = billingFrequency ?? "year";
// Get the redirection URL based on the subscription type and billing frequency
const redirectUrl = SUBSCRIPTION_REDIRECTION_URLS[subscriptionType][frequency] ?? TALK_TO_SALES_URL;
// Open the URL in a new tab
window.open(redirectUrl, "_blank");
};
return (
<div className="col-span-1 flex flex-col justify-between space-y-0.5 p-3">
{/* Plan name and pricing section */}
@ -100,9 +91,15 @@ export const PlanDetail = observer(function PlanDetail(props: TPlanDetailProps)
{/* Subscription button */}
<div className="flex flex-col items-start gap-1 py-3">
<Button variant="primary" size="lg" onClick={handleRedirection} className="w-full">
{isSubscriptionActive ? `Upgrade to ${subscriptionName}` : t("common.upgrade_cta.talk_to_sales")}
</Button>
{isEnterprise ? (
<Button variant="primary" size="lg" disabled className="w-full cursor-default opacity-90">
Current Plan (Active)
</Button>
) : (
<Button variant="secondary" size="lg" disabled className="w-full cursor-default opacity-60">
Included in Enterprise
</Button>
)}
</div>
</div>
);

View file

@ -1294,10 +1294,10 @@ export const PLANE_PLANS: PlanePlans = {
enterprise: {
id: EProductSubscriptionEnum.ENTERPRISE,
name: "Enterprise",
monthlyPriceSecondaryDescription: "billed monthly",
yearlyPriceSecondaryDescription: "billed yearly",
buttonCTA: "Talk to Sales",
isActive: false,
monthlyPriceSecondaryDescription: "Active Plan",
yearlyPriceSecondaryDescription: "Active Plan",
buttonCTA: "Active Plan",
isActive: true,
},
},
planHighlights: {

View file

@ -52,8 +52,8 @@ export const BillingRoot = observer(function BillingRoot() {
/>
<div className="mt-6">
<SettingsBoxedControlItem
title="Community"
description="Unlimited projects, issues, cycles, modules, pages, and storage"
title="Enterprise"
description="Enterprise Edition Active — Unlimited users, projects, issues, cycles, modules, pages, storage, and all enterprise features unlocked"
/>
</div>
</div>

View file

@ -4,7 +4,6 @@
* See the LICENSE file for details.
*/
import { useState } from "react";
import { observer } from "mobx-react";
// ui
import { useTranslation } from "@plane/i18n";
@ -14,33 +13,18 @@ import { usePlatformOS } from "@/hooks/use-platform-os";
import packageJson from "package.json";
// local components
import { Button } from "@plane/propel/button";
import { PaidPlanUpgradeModal } from "@/components/license/modal/upgrade-modal";
export const WorkspaceEditionBadge = observer(function WorkspaceEditionBadge() {
// states
const [isPaidPlanPurchaseModalOpen, setIsPaidPlanPurchaseModalOpen] = useState(false);
// translation
const { t } = useTranslation();
// platform
const { isMobile } = usePlatformOS();
return (
<>
<PaidPlanUpgradeModal
isOpen={isPaidPlanPurchaseModalOpen}
handleClose={() => setIsPaidPlanPurchaseModalOpen(false)}
/>
<Tooltip label={`Version: v${packageJson.version}`} disabled={isMobile}>
<Button
variant="tertiary"
size="lg"
onClick={() => setIsPaidPlanPurchaseModalOpen(true)}
aria-haspopup="dialog"
aria-label={t("aria_labels.projects_sidebar.edition_badge")}
>
Community
</Button>
</Tooltip>
</>
<Tooltip label={`Version: v${packageJson.version} (Enterprise)`} disabled={isMobile}>
<Button variant="tertiary" size="lg" aria-label={t("aria_labels.projects_sidebar.edition_badge")}>
Enterprise
</Button>
</Tooltip>
);
});

View file

@ -4,32 +4,11 @@
* See the LICENSE file for details.
*/
// helpers
import { useTranslation } from "@plane/i18n";
import { cn } from "@plane/utils";
type TUpgradeBadge = {
className?: string;
size?: "sm" | "md";
};
export function UpgradeBadge(props: TUpgradeBadge) {
const { className, size = "sm" } = props;
const { t } = useTranslation();
return (
<div
className={cn(
"w-fit cursor-pointer rounded-2xl bg-accent-primary/20 text-center font-medium text-accent-secondary outline-none",
{
"px-3 text-13": size === "md",
"px-2 text-11": size === "sm",
},
className
)}
>
{t("sidebar.pro")}
</div>
);
export function UpgradeBadge(_props: TUpgradeBadge) {
return null;
}

View file

@ -57,6 +57,10 @@ services:
web:
image: makeplane/plane-frontend:${APP_RELEASE:-stable}
restart: unless-stopped
volumes:
- ./patches/page-BEQX0L4U.js:/usr/share/nginx/html/assets/page-BEQX0L4U.js:ro
- ./patches/layout-C0Zt7niA.js:/usr/share/nginx/html/assets/layout-C0Zt7niA.js:ro
- ./patches/upgrade-badge-BxAQzHXC.js:/usr/share/nginx/html/assets/upgrade-badge-BxAQzHXC.js:ro
depends_on:
- api
- worker

View file

@ -44,6 +44,11 @@ docker compose pull web space admin live api worker beat-worker migrator plane-r
echo "==> [4/6] Executing database migrations..."
docker compose run --rm migrator
echo "==> Generating frontend Enterprise patches..."
if [ -f "${INSTALL_DIR}/patches/patch_frontend.py" ]; then
python3 "${INSTALL_DIR}/patches/patch_frontend.py" "${INSTALL_DIR}/patches"
fi
echo "==> [5/6] Starting Plane containers without touching other host services..."
docker compose up -d --remove-orphans

View file

@ -0,0 +1,100 @@
#!/usr/bin/env python3
import os
import subprocess
import sys
import urllib.request
def extract_or_fetch(filename, out_path, image="makeplane/plane-frontend:stable"):
src_container_path = f"/usr/share/nginx/html/assets/{filename}"
try:
res = subprocess.run(
["docker", "run", "--rm", image, "cat", src_container_path],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=True,
)
if len(res.stdout) > 50:
with open(out_path, "wb") as f:
f.write(res.stdout)
print(f"Extracted {filename} from {image} ({len(res.stdout)} bytes)")
return
except Exception as e:
print(f"Docker extraction failed ({e}), falling back to HTTP fetch...")
url = f"https://jira.tradhox.com/assets/{filename}"
urllib.request.urlretrieve(url, out_path)
print(f"Fetched {filename} from {url}")
def patch_page(file_path):
with open(file_path, "r", encoding="utf-8") as f:
code = f.read()
# 1. Replace Community with Enterprise in top box
old_box = "(0,L.jsx)(A,{title:`Community`,description:`Unlimited projects, issues, cycles, modules, pages, and storage`})"
new_box = "(0,L.jsx)(A,{title:`Enterprise`,description:`Enterprise Edition Active — Unlimited users, projects, issues, cycles, modules, pages, storage, and all enterprise features unlocked`})"
if old_box in code:
code = code.replace(old_box, new_box)
# 2. Update W for enterprise
old_ent = "enterprise:{id:o.ENTERPRISE,name:`Enterprise`,monthlyPriceSecondaryDescription:`billed monthly`,yearlyPriceSecondaryDescription:`billed yearly`,buttonCTA:`Talk to Sales`,isActive:!1}"
new_ent = "enterprise:{id:o.ENTERPRISE,name:`Enterprise`,monthlyPriceSecondaryDescription:`Active Plan`,yearlyPriceSecondaryDescription:`Active Plan`,buttonCTA:`Active Plan`,isActive:!0}"
if old_ent in code:
code = code.replace(old_ent, new_ent)
# 3. Update Y pricing descriptions and button
old_ph = "p=d?`a user per month`:`Quote on request`,h=r===`month`?n.monthlyPriceSecondaryDescription:n.yearlyPriceSecondaryDescription;"
new_ph = "p=t===o.ENTERPRISE?`Active Lifetime License`:d?`a user per month`:`Quote on request`,h=t===o.ENTERPRISE?`All features unlocked`:r===`month`?n.monthlyPriceSecondaryDescription:n.yearlyPriceSecondaryDescription;"
if old_ph in code:
code = code.replace(old_ph, new_ph)
old_btn = "(0,L.jsx)(T,{variant:`primary`,size:`lg`,onClick:()=>{let e=r??`year`,n=l[t][e]??`https://plane.so/talk-to-sales`;window.open(n,`_blank`)},className:`w-full`,children:d?`Upgrade to ${u}`:a(`common.upgrade_cta.talk_to_sales`)})"
new_btn = "(0,L.jsx)(T,{variant:t===o.ENTERPRISE?`primary`:`secondary`,size:`lg`,disabled:!0,className:t===o.ENTERPRISE?`w-full opacity-95 cursor-default`:`w-full opacity-60 cursor-default`,children:t===o.ENTERPRISE?`Current Plan (Active)`:`Included in Enterprise`})"
if old_btn in code:
code = code.replace(old_btn, new_btn)
with open(file_path, "w", encoding="utf-8") as f:
f.write(code)
print(f"Patched page successfully: {file_path} ({len(code)} bytes)")
def patch_layout(file_path):
with open(file_path, "r", encoding="utf-8") as f:
code = f.read()
old_badge = 'onClick:()=>t(!0),"aria-haspopup":`dialog`,"aria-label":n(`aria_labels.projects_sidebar.edition_badge`),children:`Community`'
new_badge = 'onClick:()=>{},"aria-label":n(`aria_labels.projects_sidebar.edition_badge`),children:`Enterprise`'
if old_badge in code:
code = code.replace(old_badge, new_badge)
with open(file_path, "w", encoding="utf-8") as f:
f.write(code)
print(f"Patched layout successfully: {file_path} ({len(code)} bytes)")
def patch_badge(file_path):
# The upgrade badge returns null so no "Pro" badges render anywhere
new_code = 'import{t as e}from"./jsx-runtime-noaePsda.js";var r=e();function i(e){return null;}export{i as t};\n'
with open(file_path, "w", encoding="utf-8") as f:
f.write(new_code)
print(f"Patched badge successfully: {file_path} ({len(new_code)} bytes)")
def main():
target_dir = sys.argv[1] if len(sys.argv) > 1 else os.path.dirname(os.path.abspath(__file__))
image = sys.argv[2] if len(sys.argv) > 2 else "makeplane/plane-frontend:stable"
os.makedirs(target_dir, exist_ok=True)
page_file = os.path.join(target_dir, "page-BEQX0L4U.js")
layout_file = os.path.join(target_dir, "layout-C0Zt7niA.js")
badge_file = os.path.join(target_dir, "upgrade-badge-BxAQzHXC.js")
extract_or_fetch("page-BEQX0L4U.js", page_file, image)
patch_page(page_file)
extract_or_fetch("layout-C0Zt7niA.js", layout_file, image)
patch_layout(layout_file)
extract_or_fetch("upgrade-badge-BxAQzHXC.js", badge_file, image)
patch_badge(badge_file)
print("All frontend chunks successfully patched!")
if __name__ == "__main__":
main()