From d98a3a06e66d83c371875eb21a67d4851452be90 Mon Sep 17 00:00:00 2001 From: Prateek Shourya Date: Fri, 5 Jul 2024 14:51:59 +0530 Subject: [PATCH] dev: pro plan upgrade screens. (#563) * dev: pro plan upgrade screens. * dev: website upgrade workflow * chore: plan upgrade API integration. --------- Co-authored-by: pablohashescobar --- apiserver/plane/payment/urls.py | 12 ++ apiserver/plane/payment/views/__init__.py | 8 +- apiserver/plane/payment/views/payment.py | 84 +++++++- apiserver/plane/payment/views/product.py | 48 ++++- turbo.json | 3 +- web/app/upgrade/pro/cloud/layout.tsx | 24 +++ web/app/upgrade/pro/cloud/page.tsx | 197 ++++++++++++++++++ web/app/upgrade/pro/layout.tsx | 45 ++++ web/app/upgrade/pro/page.tsx | 80 +++++++ .../components/estimates/radio-select.tsx | 2 +- .../onboarding/switch-account-dropdown.tsx | 2 + .../license/cloud-products-modal.tsx | 3 +- web/ee/components/license/plane-one-modal.tsx | 1 - web/ee/services/payment.service.ts | 10 +- web/ee/services/workspace.service.ts | 13 ++ web/ee/types/index.ts | 1 + web/ee/types/workspace.d.ts | 11 + web/public/plane-logos/plane-pro.svg | 8 + 18 files changed, 540 insertions(+), 12 deletions(-) create mode 100644 web/app/upgrade/pro/cloud/layout.tsx create mode 100644 web/app/upgrade/pro/cloud/page.tsx create mode 100644 web/app/upgrade/pro/layout.tsx create mode 100644 web/app/upgrade/pro/page.tsx create mode 100644 web/ee/types/workspace.d.ts create mode 100644 web/public/plane-logos/plane-pro.svg diff --git a/apiserver/plane/payment/urls.py b/apiserver/plane/payment/urls.py index 6aa3f93348..e81d4eeb7c 100644 --- a/apiserver/plane/payment/urls.py +++ b/apiserver/plane/payment/urls.py @@ -4,6 +4,8 @@ from .views import ( ProductEndpoint, PaymentLinkEndpoint, WorkspaceProductEndpoint, + WebsitePaymentLinkEndpoint, + WebsiteUserWorkspaceEndpoint, ) urlpatterns = [ @@ -22,4 +24,14 @@ urlpatterns = [ PaymentLinkEndpoint.as_view(), name="products", ), + path( + "website/payment-link/", + WebsitePaymentLinkEndpoint.as_view(), + name="website-payment-link", + ), + path( + "website/workspaces/", + WebsiteUserWorkspaceEndpoint.as_view(), + name="website-workspaces", + ), ] diff --git a/apiserver/plane/payment/views/__init__.py b/apiserver/plane/payment/views/__init__.py index d340f7dfe7..31235a9cfb 100644 --- a/apiserver/plane/payment/views/__init__.py +++ b/apiserver/plane/payment/views/__init__.py @@ -1,2 +1,6 @@ -from .product import ProductEndpoint, WorkspaceProductEndpoint -from .payment import PaymentLinkEndpoint +from .product import ( + ProductEndpoint, + WorkspaceProductEndpoint, + WebsiteUserWorkspaceEndpoint, +) +from .payment import PaymentLinkEndpoint, WebsitePaymentLinkEndpoint diff --git a/apiserver/plane/payment/views/payment.py b/apiserver/plane/payment/views/payment.py index d51045f5e3..f6d916aff8 100644 --- a/apiserver/plane/payment/views/payment.py +++ b/apiserver/plane/payment/views/payment.py @@ -11,7 +11,7 @@ from rest_framework.response import Response # Module imports from .base import BaseAPIView -from plane.app.permissions.workspace import WorkSpaceAdminPermission +from plane.app.permissions.workspace import WorkspaceOwnerPermission from plane.db.models import WorkspaceMember, Workspace from plane.authentication.utils.host import base_host from plane.utils.exception_logger import log_exception @@ -19,7 +19,7 @@ from plane.utils.exception_logger import log_exception class PaymentLinkEndpoint(BaseAPIView): permission_classes = [ - WorkSpaceAdminPermission, + WorkspaceOwnerPermission, ] def post(self, request, slug): @@ -78,3 +78,83 @@ class PaymentLinkEndpoint(BaseAPIView): {"error": "error fetching payment link"}, status=status.HTTP_400_BAD_REQUEST, ) + + +class WebsitePaymentLinkEndpoint(BaseAPIView): + + def post(self, request): + try: + # Get the workspace slug + slug = request.data.get("slug", False) + # Check if slug is present + if not slug: + return Response( + {"error": "slug is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # The user should be workspace admin + if not WorkspaceMember.objects.filter( + workspace__slug=slug, + member=request.user, + role=20, + is_active=True, + ).exists(): + return Response( + {"error": "You are not a admin of workspace"}, + status=status.HTTP_403_FORBIDDEN, + ) + + # Get the workspace + workspace = Workspace.objects.get(slug=slug) + + # Get the workspace members + workspace_members = ( + WorkspaceMember.objects.filter( + workspace__slug=slug, is_active=True, member__is_bot=False + ) + .annotate( + user_email=F("member__email"), user_id=F("member__id") + ) + .values("user_email", "user_id") + ) + + # Convert the user_id to string + for member in workspace_members: + member["user_id"] = str(member["user_id"]) + + # Check if the payment server base url is present + if settings.PAYMENT_SERVER_BASE_URL: + response = requests.post( + f"{settings.PAYMENT_SERVER_BASE_URL}/api/website/payment-link/", + headers={ + "content-type": "application/json", + "x-api-key": settings.PAYMENT_SERVER_AUTH_TOKEN, + }, + json={ + "workspace_id": str(workspace.id), + "slug": slug, + "customer_email": request.user.email, + "members_list": list(workspace_members), + "host": base_host(request=request, is_app=True), + }, + ) + response.raise_for_status() + response = response.json() + return Response(response, status=status.HTTP_200_OK) + else: + return Response( + {"error": "error fetching payment link"}, + status=status.HTTP_400_BAD_REQUEST, + ) + except requests.exceptions.RequestException as e: + log_exception(e) + if e.response.status_code == 400: + return Response( + e.response.json(), + status=status.HTTP_400_BAD_REQUEST, + ) + return Response( + {"error": "error fetching payment link"}, + status=status.HTTP_400_BAD_REQUEST, + ) diff --git a/apiserver/plane/payment/views/product.py b/apiserver/plane/payment/views/product.py index c4d148e922..47929df72b 100644 --- a/apiserver/plane/payment/views/product.py +++ b/apiserver/plane/payment/views/product.py @@ -3,6 +3,8 @@ import requests # Django imports from django.conf import settings +from django.db.models import CharField +from django.db.models.functions import Cast # Third party imports from rest_framework import status @@ -11,7 +13,6 @@ from rest_framework.response import Response # Module imports from .base import BaseAPIView from plane.app.permissions.workspace import ( - WorkSpaceAdminPermission, WorkspaceUserPermission, ) from plane.db.models import WorkspaceMember, Workspace @@ -20,7 +21,7 @@ from plane.utils.exception_logger import log_exception class ProductEndpoint(BaseAPIView): permission_classes = [ - WorkSpaceAdminPermission, + WorkspaceUserPermission, ] def get(self, request, slug): @@ -82,3 +83,46 @@ class WorkspaceProductEndpoint(BaseAPIView): {"error": "error fetching product details"}, status=status.HTTP_400_BAD_REQUEST, ) + + +class WebsiteUserWorkspaceEndpoint(BaseAPIView): + + def get(self, request): + # Get all the workspaces where the user is admin + workspace_query = ( + WorkspaceMember.objects.filter( + member=request.user, + is_active=True, + role=20, + ) + .annotate(uuid_str=Cast("workspace_id", CharField())) + .values("uuid_str", "workspace__slug", "workspace__name", "workspace__logo") + ) + + workspaces = [ + { + "workspace_id": workspace["uuid_str"], + "slug": workspace["workspace__slug"], + "name": workspace["workspace__name"], + "logo": workspace["workspace__logo"], + } + for workspace in workspace_query + ] + + if settings.PAYMENT_SERVER_BASE_URL: + response = requests.post( + f"{settings.PAYMENT_SERVER_BASE_URL}/api/user-workspace-products/", + headers={ + "content-type": "application/json", + "x-api-key": settings.PAYMENT_SERVER_AUTH_TOKEN, + }, + json={"workspaces": workspaces}, + ) + response.raise_for_status() + response = response.json() + return Response(response, status=status.HTTP_200_OK) + else: + return Response( + {"error": "error fetching product details"}, + status=status.HTTP_400_BAD_REQUEST, + ) diff --git a/turbo.json b/turbo.json index e76d5718b2..c660f1a004 100644 --- a/turbo.json +++ b/turbo.json @@ -26,7 +26,8 @@ "SENTRY_MONITORING_ENABLED", "NEXT_PUBLIC_PRO_PLAN_MONTHLY_REDIRECT_URL", "NEXT_PUBLIC_PRO_PLAN_YEARLY_REDIRECT_URL", - "NEXT_PUBLIC_DISCO_BASE_URL" + "NEXT_PUBLIC_DISCO_BASE_URL", + "NEXT_PUBLIC_PRO_SELF_HOSTED_PAYMENT_URL" ], "tasks": { "build": { diff --git a/web/app/upgrade/pro/cloud/layout.tsx b/web/app/upgrade/pro/cloud/layout.tsx new file mode 100644 index 0000000000..d356c0bb65 --- /dev/null +++ b/web/app/upgrade/pro/cloud/layout.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { ReactNode } from "react"; +// components +import { PageHead } from "@/components/core"; +// wrappers +import { AuthenticationWrapper } from "@/lib/wrappers"; + +type Props = { + children: ReactNode; +}; + +export default function CloudUpgradeLayout(props: Props) { + const { children } = props; + + return ( +
+ + + {children} + +
+ ); +} diff --git a/web/app/upgrade/pro/cloud/page.tsx b/web/app/upgrade/pro/cloud/page.tsx new file mode 100644 index 0000000000..d1ce095797 --- /dev/null +++ b/web/app/upgrade/pro/cloud/page.tsx @@ -0,0 +1,197 @@ +"use client"; + +import { useState } from "react"; +import { observer } from "mobx-react"; +// ui +import Link from "next/link"; +import { useTheme } from "next-themes"; +import useSWR from "swr"; +import { Button, TOAST_TYPE, Tooltip, getButtonStyling, setToast } from "@plane/ui"; +// components +import { LogoSpinner } from "@/components/common"; +import { RadioInput } from "@/components/estimates"; +// helpers +import { cn } from "@/helpers/common.helper"; +import { truncateText } from "@/helpers/string.helper"; +// hooks +import { useUser } from "@/hooks/store"; +import { useAppRouter } from "@/hooks/use-app-router"; +// services +import { WorkspaceService } from "@/plane-web/services"; +import { PaymentService } from "@/plane-web/services/payment.service"; + +const workspaceService = new WorkspaceService(); +const paymentService = new PaymentService(); + +const CloudUpgradePage = observer(() => { + // router + const router = useAppRouter(); + // states + const [isLoading, setIsLoading] = useState(false); + const [selectedWorkspace, setSelectedWorkspace] = useState(""); + // hooks + const { data: currentUser, signOut } = useUser(); + // next themes + const { setTheme } = useTheme(); + + const { data: workspacesList, isLoading: isFetching } = useSWR( + currentUser ? `WORKSPACES_WITH_PLAN_DETAILS` : null, + currentUser ? () => workspaceService.getWorkspacesWithPlanDetails() : null, + { revalidateIfStale: false, revalidateOnFocus: false } + ); + + if (isFetching) { + return ( +
+ +
+ ); + } + + 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 handleSignOut = async () => { + setIsLoading(true); + await signOut() + .then(() => { + setTheme("system"); + router.push("/"); + }) + .catch(() => + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Failed to sign out. Please try again.", + }) + ) + .finally(() => setIsLoading(false)); + }; + + return ( +
+ +
+ {isAnyWorkspaceAvailable ? "Choose your workspace" : "No eligible workspace found!"} +
+
+ {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.`} +
+
+ } + options={ + isAnyWorkspaceAvailable + ? workspacesList.map((workspace) => ({ + label: ( +
+
+
+ {workspace?.logo && workspace.logo !== "" ? ( + {workspace.name} + ) : ( + + {workspace?.name[0]} + + )} +
+
+
+
{truncateText(workspace?.name, 40)}
+
+ {workspace.product === "PRO" && ( +
+ +
+ Pro +
+
+
+ )} +
+ ), + value: workspace.slug, + disabled: workspace.product !== "FREE", + })) + : [] + } + className="w-full" + wrapperClassName={cn({ + "w-full max-h-72 overflow-auto vertical-scrollbar scrollbar-sm flex flex-col gap-5 p-5 border border-custom-border-200 rounded-md": + isAnyWorkspaceAvailable, + })} + buttonClassName="size-3.5 mt-0.5" + selected={selectedWorkspace} + onChange={(value) => setSelectedWorkspace(value)} + /> + {isAnyWorkspaceAvailable ? ( + + ) : ( +
+ + + Create a new workspace + +
+ )} + + ); +}); + +export default CloudUpgradePage; diff --git a/web/app/upgrade/pro/layout.tsx b/web/app/upgrade/pro/layout.tsx new file mode 100644 index 0000000000..647d8e99d2 --- /dev/null +++ b/web/app/upgrade/pro/layout.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { ReactNode } from "react"; +import Image from "next/image"; +import Link from "next/link"; +// components +import { PageHead } from "@/components/core"; +// helpers +import { SwitchAccountDropdown } from "@/components/onboarding"; +import { EPageTypes } from "@/helpers/authentication.helper"; +// wrappers +import { AuthenticationWrapper } from "@/lib/wrappers"; +// assets +import PlaneProLogo from "@/public/plane-logos/plane-pro.svg"; + +type Props = { + children: ReactNode; +}; + +export default function UpgradePlanLayout(props: Props) { + const { children } = props; + + return ( +
+ + +
+
+
+ + Plane pro logo + +
+
+ +
+
+
+ {children} +
+
+
+
+ ); +} diff --git a/web/app/upgrade/pro/page.tsx b/web/app/upgrade/pro/page.tsx new file mode 100644 index 0000000000..32da7b75a5 --- /dev/null +++ b/web/app/upgrade/pro/page.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { useState } from "react"; +// ui +import { Button, TOAST_TYPE, setToast } from "@plane/ui"; +// components +import { RadioInput } from "@/components/estimates"; +// hooks +import { useAppRouter } from "@/hooks/use-app-router"; + +type TPlaneEditions = { + [key: string]: { + title: string; + description: string; + }; +}; + +const PLANE_EDITIONS: TPlaneEditions = { + cloud: { + title: "Cloud account at app.plane.so", + description: "You will log into your Plane account and select the workspace you want to upgrade", + }, + "self-hosted": { + title: "Self-hosted Plane", + description: "Choose this if you self-host the Community Edition or One", + }, +}; + +export default function UpgradePlanPage() { + const router = useAppRouter(); + // states + const [selectedEdition, setSelectedEdition] = useState("cloud"); + + const handleNextStep = () => { + if (!selectedEdition) { + setToast({ + type: TOAST_TYPE.INFO, + title: "Please select an edition to continue", + }); + return; + } + + if (selectedEdition === "cloud") { + router.push("/upgrade/pro/cloud"); + } + if (selectedEdition === "self-hosted") { + if (process.env.NEXT_PUBLIC_PRO_SELF_HOSTED_PAYMENT_URL) { + window.open(process.env.NEXT_PUBLIC_PRO_SELF_HOSTED_PAYMENT_URL, "_blank"); + } + } + }; + + return ( +
+ ({ + label: ( +
+
{PLANE_EDITIONS[edition].title}
+
{PLANE_EDITIONS[edition].description}
+
+ ), + value: edition, + }))} + className="w-full" + labelClassName="text-center text-3xl font-semibold pb-6" + wrapperClassName="w-full flex flex-col gap-4" + fieldClassName="border border-custom-border-200 rounded-md py-2 px-4 items-start" + buttonClassName="size-3.5 mt-1" + selected={selectedEdition} + onChange={(value) => setSelectedEdition(value)} + /> + +
+ ); +} diff --git a/web/core/components/estimates/radio-select.tsx b/web/core/components/estimates/radio-select.tsx index f8569ea881..6a35f14e32 100644 --- a/web/core/components/estimates/radio-select.tsx +++ b/web/core/components/estimates/radio-select.tsx @@ -62,7 +62,7 @@ export const RadioInput = ({ id={`${name}_${index}`} name={name} className={cn( - `group flex size-5 items-center justify-center rounded-full border border-custom-border-400 bg-custom-background-500 cursor-pointer`, + `group flex flex-shrink-0 size-5 items-center justify-center rounded-full border border-custom-border-400 bg-custom-background-500 cursor-pointer`, selected === value ? `bg-custom-primary-200 border-custom-primary-100 ` : ``, disabled ? `bg-custom-background-200 border-custom-border-200 cursor-not-allowed` : ``, inputButtonClassName diff --git a/web/core/components/onboarding/switch-account-dropdown.tsx b/web/core/components/onboarding/switch-account-dropdown.tsx index e3044c7162..521b35726a 100644 --- a/web/core/components/onboarding/switch-account-dropdown.tsx +++ b/web/core/components/onboarding/switch-account-dropdown.tsx @@ -30,6 +30,8 @@ export const SwitchAccountDropdown: FC = observer(( ? fullName : user?.email; + if (!displayName && !fullName) return null; + return (
setShowSwitchAccountModal(false)} /> diff --git a/web/ee/components/license/cloud-products-modal.tsx b/web/ee/components/license/cloud-products-modal.tsx index 81cc748994..c61a13c7b7 100644 --- a/web/ee/components/license/cloud-products-modal.tsx +++ b/web/ee/components/license/cloud-products-modal.tsx @@ -71,12 +71,11 @@ export const CloudProductsModal: FC = (props) => { setLoading(true); captureEvent("pro_plan_payment_link_clicked", { workspaceSlug }); paymentService - .getPaymentLink(workspaceSlug.toString(), { + .getCurrentWorkspacePaymentLink(workspaceSlug.toString(), { price_id: priceId, product_id: proProduct?.id, }) .then((response) => { - console.log("response", response); if (response.payment_link) { window.open(response.payment_link, "_blank"); } diff --git a/web/ee/components/license/plane-one-modal.tsx b/web/ee/components/license/plane-one-modal.tsx index affbbdbff2..ef477c78a8 100644 --- a/web/ee/components/license/plane-one-modal.tsx +++ b/web/ee/components/license/plane-one-modal.tsx @@ -31,7 +31,6 @@ export const PlaneOneModal: FC = observer((props) => { const instanceService = new InstanceService(); const { data } = useSWR("INSTANCE_CHANGELOG", () => instanceService.getInstanceChangeLog()); - console.log("data", data); return ( diff --git a/web/ee/services/payment.service.ts b/web/ee/services/payment.service.ts index 2d0ea9bae9..cbeb5824ed 100644 --- a/web/ee/services/payment.service.ts +++ b/web/ee/services/payment.service.ts @@ -17,7 +17,7 @@ export class PaymentService extends APIService { }); } - getPaymentLink(workspaceSlug: string, data = {}) { + getCurrentWorkspacePaymentLink(workspaceSlug: string, data = {}) { return this.post(`/api/payments/workspaces/${workspaceSlug}/payment-link/`, data) .then((response) => response?.data) .catch((error) => { @@ -32,4 +32,12 @@ export class PaymentService extends APIService { throw error?.response?.data; }); } + + async getPaymentLink(data = {}) { + return this.post(`/api/payments/website/payment-link/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } } diff --git a/web/ee/services/workspace.service.ts b/web/ee/services/workspace.service.ts index b687b4c28a..a7ce09030d 100644 --- a/web/ee/services/workspace.service.ts +++ b/web/ee/services/workspace.service.ts @@ -1,5 +1,10 @@ +// constants import { EViewAccess } from "@/constants/views"; +// helpers import { API_BASE_URL } from "@/helpers/common.helper"; +// types +import { TWorkspaceWithProductDetails } from "@/plane-web/types"; +// services import { WorkspaceService as CoreWorkspaceService } from "@/services/workspace.service"; export class WorkspaceService extends CoreWorkspaceService { @@ -26,4 +31,12 @@ export class WorkspaceService extends CoreWorkspaceService { throw error?.response?.data; }); } + + async getWorkspacesWithPlanDetails(): Promise { + return this.get(`/api/payments/website/workspaces/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } } diff --git a/web/ee/types/index.ts b/web/ee/types/index.ts index 6228823fe9..01b13f23a2 100644 --- a/web/ee/types/index.ts +++ b/web/ee/types/index.ts @@ -1,2 +1,3 @@ export * from "./app"; export * from "./pages"; +export * from "./workspace"; diff --git a/web/ee/types/workspace.d.ts b/web/ee/types/workspace.d.ts new file mode 100644 index 0000000000..a61eb36770 --- /dev/null +++ b/web/ee/types/workspace.d.ts @@ -0,0 +1,11 @@ +// types +import { TProductSubscriptionType } from "@plane/types"; + +export type TWorkspaceWithProductDetails = { + workspace_id: string; + slug: string; + name: string; + logo: string; + product: TProductSubscriptionType; + current_period_end_date: string; +}; \ No newline at end of file diff --git a/web/public/plane-logos/plane-pro.svg b/web/public/plane-logos/plane-pro.svg new file mode 100644 index 0000000000..15260804a4 --- /dev/null +++ b/web/public/plane-logos/plane-pro.svg @@ -0,0 +1,8 @@ + + + + + + + +