mirror of
https://github.com/makeplane/plane.git
synced 2026-07-12 05:21:14 +02:00
feat pricing changes (#819)
* chore: current plan is_offline_payment added * chore: upgrade flow improvement
This commit is contained in:
committed by
GitHub
parent
cef277a9c9
commit
36a5ad6833
1
packages/types/src/payment.d.ts
vendored
1
packages/types/src/payment.d.ts
vendored
@@ -22,4 +22,5 @@ export type IWorkspaceProductSubscription = {
|
||||
is_canceled?: boolean;
|
||||
interval?: "month" | "year" | null;
|
||||
current_period_end_date: string | null;
|
||||
is_offline_payment: boolean;
|
||||
};
|
||||
|
||||
176
web/app/upgrade/pro/cloud/[selectedWorkspace]/page.tsx
Normal file
176
web/app/upgrade/pro/cloud/[selectedWorkspace]/page.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
import { Check } from "lucide-react";
|
||||
// types
|
||||
import { IPaymentProduct, IPaymentProductPrice } from "@plane/types";
|
||||
// ui
|
||||
import { Button, Loader, setToast, TOAST_TYPE } from "@plane/ui";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// services
|
||||
import { WorkspaceService } from "@/plane-web/services";
|
||||
import { PaymentService } from "@/plane-web/services/payment.service";
|
||||
|
||||
const paymentService = new PaymentService();
|
||||
const workspaceService = new WorkspaceService();
|
||||
|
||||
const CloudUpgradePlanPage = observer(() => {
|
||||
// states
|
||||
const [selectedPlan, setSelectedPlan] = useState<IPaymentProductPrice | null>(null);
|
||||
const [isLoading, setLoading] = useState(false);
|
||||
// router
|
||||
const { selectedWorkspace } = useParams();
|
||||
|
||||
// fetch workspace members
|
||||
const { data: workspaceMembers, isLoading: isFetching } = useSWR(
|
||||
selectedWorkspace ? `WORKSPACES_WITH_PLAN_DETAILS` : null,
|
||||
selectedWorkspace ? () => workspaceService.fetchWorkspaceMembers(selectedWorkspace.toString()) : null,
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
// fetch products
|
||||
const { data, isLoading: isLoadingProduct } = useSWR(
|
||||
selectedWorkspace ? "CLOUD_PAYMENT_PRODUCTS" : null,
|
||||
selectedWorkspace ? () => paymentService.listProducts(selectedWorkspace.toString()) : null,
|
||||
{
|
||||
errorRetryCount: 2,
|
||||
revalidateIfStale: false,
|
||||
revalidateOnFocus: false,
|
||||
}
|
||||
);
|
||||
|
||||
const totalWorkspaceMembers = (workspaceMembers || [])?.filter((member) => member.role >= 15)?.length;
|
||||
|
||||
const proProduct = (data || [])?.find((product: IPaymentProduct) => product?.type === "PRO");
|
||||
|
||||
const monthlyPlan = proProduct?.prices?.find((price) => price.recurring === "month");
|
||||
const yearlyPlan = proProduct?.prices?.find((price) => price.recurring === "year");
|
||||
|
||||
const calculateYearlyDiscount = (monthlyAmount: number | undefined, yearlyAmount: number | undefined) => {
|
||||
if (!monthlyAmount || !yearlyAmount) return undefined;
|
||||
const yearlyCostIfPaidMonthly = monthlyAmount * 12;
|
||||
const discountAmount = yearlyCostIfPaidMonthly - yearlyAmount;
|
||||
const discountPercentage = (discountAmount / yearlyCostIfPaidMonthly) * 100;
|
||||
|
||||
return discountPercentage;
|
||||
};
|
||||
|
||||
const discountedPrice = calculateYearlyDiscount(monthlyPlan?.unit_amount, yearlyPlan?.unit_amount);
|
||||
|
||||
const handleStripeCheckout = (priceId: string) => {
|
||||
if (!selectedWorkspace) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.INFO,
|
||||
title: "Please select a workspace to continue",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
paymentService
|
||||
.getCurrentWorkspacePaymentLink(selectedWorkspace.toString(), {
|
||||
price_id: priceId,
|
||||
product_id: proProduct?.id,
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.payment_link) {
|
||||
window.open(response.payment_link, "_self");
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: error?.detail ?? "Failed to generate payment link. Please try again.",
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center gap-2">
|
||||
<div className="flex flex-col items-center gap-2 pb-4">
|
||||
<div className="text-3xl font-semibold">Choose your billing frequency</div>
|
||||
{discountedPrice && (
|
||||
<div className="text-center text-base text-custom-text-300">{`Upgrade to our yearly plan and get ${Math.round(discountedPrice)}% off.`}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isFetching || isLoadingProduct ? (
|
||||
<div className="flex flex-col rounded gap-1 w-full">
|
||||
<Loader.Item height="90px" />
|
||||
<Loader.Item height="90px" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col rounded w-full">
|
||||
{proProduct &&
|
||||
proProduct.prices.map((plan) => (
|
||||
<div
|
||||
key={plan.unit_amount}
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-6 border-x border border-custom-border-200 cursor-pointer rounded py-6 px-4 first:rounded-b-none last:rounded-t-none",
|
||||
{
|
||||
"border border-custom-primary-100": plan.recurring === selectedPlan?.recurring,
|
||||
}
|
||||
)}
|
||||
onClick={() => setSelectedPlan(plan)}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<span
|
||||
className={cn("flex items-center justify-center size-6 rounded-full", {
|
||||
"bg-custom-primary-100 text-white": plan.recurring === selectedPlan?.recurring,
|
||||
"border border-custom-border-200": plan.recurring !== selectedPlan?.recurring,
|
||||
})}
|
||||
>
|
||||
{plan.recurring === selectedPlan?.recurring && <Check className="size-4 stroke-2" />}
|
||||
</span>
|
||||
<div className="flex flex-col">
|
||||
{totalWorkspaceMembers && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl font-semibold leading-7">{`$${plan.unit_amount / 100}`}</span>
|
||||
<span className="text-sm text-custom-text-300">
|
||||
{` per user per ${plan.recurring} x ${totalWorkspaceMembers}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<span className="text-sm text-custom-text-300">
|
||||
{`Billed ${plan.recurring === "month" ? "monthly" : "yearly"}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
{totalWorkspaceMembers && (
|
||||
<span className="text-2xl font-semibold leading-7">{`$${(plan.unit_amount / 100) * totalWorkspaceMembers}`}</span>
|
||||
)}
|
||||
<span className="text-sm text-custom-text-300">
|
||||
{plan.recurring === "month" ? "per month" : "per year"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{selectedPlan?.recurring && selectedPlan.unit_amount ? (
|
||||
<>
|
||||
<Button className="w-full px-2 my-4" onClick={() => handleStripeCheckout(selectedPlan.id)}>
|
||||
{isLoading
|
||||
? "Redirecting to Stripe..."
|
||||
: `Pay $${(selectedPlan?.unit_amount / 100) * totalWorkspaceMembers} every ${selectedPlan?.recurring}`}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button className="w-full px-2 my-4" disabled>{`Select a plan to continue`}</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default CloudUpgradePlanPage;
|
||||
@@ -50,34 +50,8 @@ const CloudUpgradePage = observer(() => {
|
||||
|
||||
const isAnyWorkspaceAvailable = workspacesList && workspacesList?.length > 0;
|
||||
|
||||
const handlePaymentPageRedirection = () => {
|
||||
if (!selectedWorkspace) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.INFO,
|
||||
title: "Please select a workspace to continue",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
paymentService
|
||||
.getPaymentLink({
|
||||
slug: selectedWorkspace,
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.payment_link) {
|
||||
window.open(response.payment_link, "_blank");
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Failed to generate payment link. Please try again.",
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
const handleNextStep = () => {
|
||||
router.push(`/upgrade/pro/cloud/${selectedWorkspace}`);
|
||||
};
|
||||
|
||||
const handleSignOut = async () => {
|
||||
@@ -104,60 +78,59 @@ const CloudUpgradePage = observer(() => {
|
||||
label={
|
||||
<div className="flex flex-col items-center gap-2 pb-4">
|
||||
<div className="text-3xl font-semibold">
|
||||
{isAnyWorkspaceAvailable ? "Choose your workspace" : "No eligible workspace found!"}
|
||||
{isAnyWorkspaceAvailable
|
||||
? "Choose your workspace"
|
||||
: "We didn't find an eligible workspace for this upgrade. Try another email address."}
|
||||
</div>
|
||||
<div className="text-center text-base text-custom-text-300">
|
||||
{isAnyWorkspaceAvailable
|
||||
? `We found the following workspaces eligible for Pro. If you want to upgrade a different workspace, log in
|
||||
with that email`
|
||||
: `We couldn't find any Pro eligible workspace. Try a different email address and make sure you are an admin of the workspace you are trying to upgrade.`}
|
||||
with that email and make sure you are an admin of the workspace you want to upgrade.`
|
||||
: `We couldn't find any Pro eligible workspace. Try a different email address and make sure you are an admin of the workspace you want to upgrade.`}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
options={
|
||||
isAnyWorkspaceAvailable
|
||||
? workspacesList.map((workspace) => ({
|
||||
label: (
|
||||
<div className={`flex items-center gap-3 px-1`}>
|
||||
<div className="flex-shrink-0">
|
||||
<div className="relative grid h-7 w-7 place-items-center rounded">
|
||||
{workspace?.logo && workspace.logo !== "" ? (
|
||||
<img
|
||||
src={workspace.logo}
|
||||
className="absolute left-0 top-0 h-full w-full rounded object-cover"
|
||||
alt={workspace.name}
|
||||
/>
|
||||
) : (
|
||||
<span className="grid h-7 w-7 justify-center place-items-center rounded bg-gray-700 px-3 py-1.5 uppercase text-sm text-white">
|
||||
{workspace?.name[0]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium">{truncateText(workspace?.name, 40)}</div>
|
||||
</div>
|
||||
{workspace.product === "PRO" && (
|
||||
label: (
|
||||
<div className={`flex items-center gap-3 px-1`}>
|
||||
<div className="flex-shrink-0">
|
||||
<Tooltip
|
||||
position="right"
|
||||
tooltipContent="You're already subscribed to pro plan for this workspace."
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"text-[#EA9924] bg-[#FFF7C2] rounded-md px-2 py-0 text-center text-xs font-medium outline-none"
|
||||
)}
|
||||
>
|
||||
Pro
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div className="relative grid h-7 w-7 place-items-center rounded">
|
||||
{workspace?.logo && workspace.logo !== "" ? (
|
||||
<img
|
||||
src={workspace.logo}
|
||||
className="absolute left-0 top-0 h-full w-full rounded object-cover"
|
||||
alt={workspace.name}
|
||||
/>
|
||||
) : (
|
||||
<span className="grid h-7 w-7 justify-center place-items-center rounded bg-gray-700 px-3 py-1.5 uppercase text-sm text-white">
|
||||
{workspace?.name[0]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
value: workspace.slug,
|
||||
disabled: workspace.product !== "FREE",
|
||||
}))
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium">{truncateText(workspace?.name, 40)}</div>
|
||||
</div>
|
||||
{workspace.product === "PRO" && (
|
||||
<div className="flex-shrink-0">
|
||||
<Tooltip position="right" tooltipContent="This workspace is already subscribed to Pro.">
|
||||
<div
|
||||
className={cn(
|
||||
"text-[#EA9924] bg-[#FFF7C2] rounded-md px-2 py-0 text-center text-xs font-medium outline-none"
|
||||
)}
|
||||
>
|
||||
Pro
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
value: workspace.slug,
|
||||
disabled: workspace.product !== "FREE",
|
||||
}))
|
||||
: []
|
||||
}
|
||||
className="w-full"
|
||||
@@ -172,11 +145,11 @@ const CloudUpgradePage = observer(() => {
|
||||
{isAnyWorkspaceAvailable ? (
|
||||
<Button
|
||||
className="w-full px-2 my-4"
|
||||
onClick={handlePaymentPageRedirection}
|
||||
onClick={handleNextStep}
|
||||
loading={isLoading}
|
||||
disabled={isLoading || !selectedWorkspace}
|
||||
>
|
||||
{isLoading ? "Redirecting to Stripe..." : "Go to payment"}
|
||||
{isLoading ? "Going to payment" : "Choose billing frequency"}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="w-full flex gap-4 px-4">
|
||||
|
||||
@@ -107,21 +107,23 @@ export const PlaneCloudBilling: React.FC = observer(() => {
|
||||
(Expires on: {renderFormattedDate(endDate)})
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-sm text-custom-text-200 font-medium">
|
||||
(Renew on: {renderFormattedDate(endDate)})
|
||||
</div>
|
||||
<div className="text-center text-sm text-custom-text-200 font-medium">
|
||||
(Renew on: {renderFormattedDate(endDate)})
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
variant="neutral-primary"
|
||||
className="cursor-pointer rounded-2xl px-3 py-1.5 text-center text-sm font-medium outline-none"
|
||||
onClick={handleSubscriptionPageRedirection}
|
||||
>
|
||||
{isLoading ? "Redirecting to Stripe..." : "Manage your subscriptions"}
|
||||
<ExternalLink className="h-3 w-3" strokeWidth={2} />
|
||||
</Button>
|
||||
</div>
|
||||
{!currentWorkspaceSubscribedPlanDetail.is_offline_payment && (
|
||||
<div>
|
||||
<Button
|
||||
variant="neutral-primary"
|
||||
className="cursor-pointer rounded-2xl px-3 py-1.5 text-center text-sm font-medium outline-none"
|
||||
onClick={handleSubscriptionPageRedirection}
|
||||
>
|
||||
{isLoading ? "Redirecting to Stripe..." : "Manage your subscriptions"}
|
||||
<ExternalLink className="h-3 w-3" strokeWidth={2} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user