feat pricing changes (#819)

* chore: current plan is_offline_payment added

* chore: upgrade flow improvement
This commit is contained in:
Anmol Singh Bhatia
2024-08-13 15:46:53 +05:30
committed by GitHub
parent cef277a9c9
commit 36a5ad6833
4 changed files with 237 additions and 85 deletions

View File

@@ -22,4 +22,5 @@ export type IWorkspaceProductSubscription = {
is_canceled?: boolean;
interval?: "month" | "year" | null;
current_period_end_date: string | null;
is_offline_payment: boolean;
};

View 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;

View File

@@ -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">

View File

@@ -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>
)}