feat(billing): unlock Enterprise plan and all workspace features
All checks were successful
Deploy Plane Project Tracker / Deploy to cisystem (push) Successful in 31s
All checks were successful
Deploy Plane Project Tracker / Deploy to cisystem (push) Successful in 31s
This commit is contained in:
parent
283386386c
commit
ed2e1134d1
10 changed files with 148 additions and 91 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))` }}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
100
deploy/patches/patch_frontend.py
Normal file
100
deploy/patches/patch_frontend.py
Normal 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()
|
||||
Loading…
Reference in a new issue