fix: pro plane fixes

This commit is contained in:
sriram veeraghanta
2024-06-17 16:36:09 +05:30
33 changed files with 821 additions and 18 deletions

View File

@@ -27,6 +27,7 @@ from plane.db.models import (
WorkspaceMember,
IssueProperty,
)
from plane.payment.bgtasks.member_sync_task import member_sync_task
class ProjectInvitationsViewset(BaseViewSet):
@@ -247,6 +248,9 @@ class ProjectJoinEndpoint(BaseAPIView):
workspace_member.is_active = True
workspace_member.save()
# Sync workspace members
member_sync_task.delay(slug)
# Check if the user was already a member of project then activate the user
project_member = ProjectMember.objects.filter(
workspace_id=project_invite.workspace_id, member=user

View File

@@ -35,6 +35,7 @@ from plane.license.models import Instance, InstanceAdmin
from plane.utils.cache import cache_response, invalidate_cache
from plane.utils.paginator import BasePaginator
from plane.authentication.utils.host import user_ip
from plane.payment.bgtasks.member_sync_task import member_sync_task
class UserEndpoint(BaseViewSet):
@@ -159,6 +160,12 @@ class UserEndpoint(BaseViewSet):
workspaces_to_deactivate, ["is_active"], batch_size=100
)
# Sync workspace members
[
member_sync_task.delay(workspace.workspace.slug)
for workspace in workspaces_to_deactivate
]
# Delete all workspace invites
WorkspaceMemberInvite.objects.filter(
email=user.email,

View File

@@ -44,6 +44,7 @@ from plane.db.models import (
WorkspaceTheme,
)
from plane.utils.cache import cache_response, invalidate_cache
from plane.payment.bgtasks.member_sync_task import member_sync_task
class WorkSpaceViewSet(BaseViewSet):
@@ -127,6 +128,9 @@ class WorkSpaceViewSet(BaseViewSet):
role=20,
company_role=request.data.get("company_role", ""),
)
# Sync workspace members
member_sync_task.delay(slug)
return Response(
serializer.data, status=status.HTTP_201_CREATED
)

View File

@@ -31,7 +31,7 @@ from plane.db.models import (
WorkspaceMemberInvite,
)
from plane.utils.cache import invalidate_cache, invalidate_cache_directly
from plane.payment.bgtasks.member_sync_task import member_sync_task
from .. import BaseViewSet
@@ -243,6 +243,9 @@ class WorkspaceJoinEndpoint(BaseAPIView):
},
)
# sync workspace members
member_sync_task.delay(slug)
return Response(
{"message": "Workspace Invitation Accepted"},
status=status.HTTP_200_OK,
@@ -315,6 +318,12 @@ class UserWorkspaceInvitationsViewSet(BaseViewSet):
ignore_conflicts=True,
)
# Sync workspace members
[
member_sync_task.delay(invitation.workspace.slug)
for invitation in workspace_invitations
]
# Delete joined workspace invites
workspace_invitations.delete()

View File

@@ -35,7 +35,7 @@ from plane.db.models import (
WorkspaceMember,
)
from plane.utils.cache import cache_response, invalidate_cache
from plane.payment.bgtasks.member_sync_task import member_sync_task
from .. import BaseViewSet
@@ -221,6 +221,10 @@ class WorkSpaceMemberViewSet(BaseViewSet):
workspace_member.is_active = False
workspace_member.save()
# Sync workspace members
member_sync_task.delay(slug)
return Response(status=status.HTTP_204_NO_CONTENT)
@invalidate_cache(
@@ -288,6 +292,9 @@ class WorkSpaceMemberViewSet(BaseViewSet):
# # Deactivate the user
workspace_member.is_active = False
workspace_member.save()
# # Sync workspace members
member_sync_task.delay(slug)
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@@ -5,6 +5,7 @@ from plane.db.models import (
WorkspaceMemberInvite,
)
from plane.utils.cache import invalidate_cache_directly
from plane.payment.bgtasks.member_sync_task import member_sync_task
def process_workspace_project_invitations(user):
@@ -37,6 +38,12 @@ def process_workspace_project_invitations(user):
for workspace_member_invite in workspace_member_invites
]
# Sync workspace members
[
member_sync_task.delay(workspace_member_invite.workspace.slug)
for workspace_member_invite in workspace_member_invites
]
# Check if user has any project invites
project_member_invites = ProjectMemberInvite.objects.filter(
email=user.email, accepted=True
@@ -78,6 +85,12 @@ def process_workspace_project_invitations(user):
ignore_conflicts=True,
)
# Sync workspace members
[
member_sync_task.delay(project_member_invite.workspace.slug)
for project_member_invite in project_member_invites
]
# Delete all the invites
workspace_member_invites.delete()
project_member_invites.delete()

View File

@@ -28,6 +28,7 @@ from plane.db.models import (
)
from plane.bgtasks.user_welcome_task import send_welcome_slack
from plane.payment.bgtasks.member_sync_task import member_sync_task
@shared_task
@@ -100,6 +101,9 @@ def service_importer(service, importer_id):
ignore_conflicts=True,
)
# Sync workspace members
member_sync_task(importer.workspace.slug)
ProjectMember.objects.bulk_create(
[
ProjectMember(

View File

View File

@@ -0,0 +1,5 @@
from django.apps import AppConfig
class ApiConfig(AppConfig):
name = "plane.enterprise"

View File

@@ -0,0 +1,12 @@
import requests
# django rest framework
from rest_framework.views import APIView
from rest_framework import status
from rest_framework.response import Response
class ProductsView(APIView):
def list(self):
response = requests.get()
return Response("Hello World")

View File

View File

@@ -0,0 +1,5 @@
from django.apps import AppConfig
class ApiConfig(AppConfig):
name = "plane.payment"

View File

@@ -0,0 +1,57 @@
# Python imports
import requests
# Django imports
from django.conf import settings
from django.db.models import F
# Third party imports
from celery import shared_task
# Module imports
from plane.db.models import WorkspaceMember, Workspace
@shared_task
def member_sync_task(slug):
# Do not run this task if payment server base url is not set
if settings.PAYMENT_SERVER_BASE_URL:
# workspace from slug
workspace = Workspace.objects.filter(slug=slug).first()
workspace_id = str(workspace.id)
# Get all active workspace members
workspace_members = (
WorkspaceMember.objects.filter(
workspace_id=workspace_id, is_active=True, member__is_bot=False
)
.annotate(user_email=F("member__email"), user_id=F("member__id"))
.values("user_email", "user_id")
)
# Convert user_id to string
for member in workspace_members:
member["user_id"] = str(member["user_id"])
# Send request to payment server to sync workspace members
response = requests.patch(
f"{settings.PAYMENT_SERVER_BASE_URL}/api/workspaces/{workspace_id}/subscriptions/",
json={
"slug": slug,
"workspace_id": str(workspace_id),
"members_list": list(workspace_members),
},
headers={"content-type": "application/json"},
)
# Check if response is successful
if response.status_code == 200:
return
# Workspace does not have a subscription
elif response.status_code == 404:
return
# Invalid request
else:
return
else:
return

View File

@@ -0,0 +1,25 @@
from django.urls import path
from .views import (
ProductEndpoint,
PaymentLinkEndpoint,
WorkspaceProductEndpoint,
)
urlpatterns = [
path(
"workspaces/<str:slug>/products/",
ProductEndpoint.as_view(),
name="products",
),
path(
"workspaces/<str:slug>/current-plan/",
WorkspaceProductEndpoint.as_view(),
name="products",
),
path(
"workspaces/<str:slug>/payment-link/",
PaymentLinkEndpoint.as_view(),
name="products",
),
]

View File

@@ -0,0 +1,2 @@
from .product import ProductEndpoint, WorkspaceProductEndpoint
from .payment import PaymentLinkEndpoint

View File

@@ -0,0 +1,132 @@
# Python imports
import zoneinfo
# Django imports
from django.conf import settings
from django.utils import timezone
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.db import IntegrityError
# Third party imports
from rest_framework import status
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.permissions import IsAuthenticated
# Module imports
from plane.authentication.session import BaseSessionAuthentication
from plane.utils.exception_logger import log_exception
class TimezoneMixin:
"""
This enables timezone conversion according
to the user set timezone
"""
def initial(self, request, *args, **kwargs):
super().initial(request, *args, **kwargs)
if request.user.is_authenticated:
timezone.activate(zoneinfo.ZoneInfo(request.user.user_timezone))
else:
timezone.deactivate()
class BaseAPIView(TimezoneMixin, APIView):
permission_classes = [
IsAuthenticated,
]
authentication_classes = [
BaseSessionAuthentication,
]
filterset_fields = []
search_fields = []
def filter_queryset(self, queryset):
for backend in list(self.filter_backends):
queryset = backend().filter_queryset(self.request, queryset, self)
return queryset
def handle_exception(self, exc):
"""
Handle any exception that occurs, by returning an appropriate response,
or re-raising the error.
"""
try:
response = super().handle_exception(exc)
return response
except Exception as e:
if isinstance(e, IntegrityError):
return Response(
{"error": "The payload is not valid"},
status=status.HTTP_400_BAD_REQUEST,
)
if isinstance(e, ValidationError):
return Response(
{"error": "Please provide valid detail"},
status=status.HTTP_400_BAD_REQUEST,
)
if isinstance(e, ObjectDoesNotExist):
return Response(
{"error": "The required object does not exist."},
status=status.HTTP_404_NOT_FOUND,
)
if isinstance(e, KeyError):
return Response(
{"error": "The required key does not exist."},
status=status.HTTP_400_BAD_REQUEST,
)
log_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
def dispatch(self, request, *args, **kwargs):
try:
response = super().dispatch(request, *args, **kwargs)
if settings.DEBUG:
from django.db import connection
print(
f"{request.method} - {request.get_full_path()} of Queries: {len(connection.queries)}"
)
return response
except Exception as exc:
response = self.handle_exception(exc)
return exc
@property
def workspace_slug(self):
return self.kwargs.get("slug", None)
@property
def project_id(self):
return self.kwargs.get("project_id", None)
@property
def fields(self):
fields = [
field
for field in self.request.GET.get("fields", "").split(",")
if field
]
return fields if fields else None
@property
def expand(self):
expand = [
expand
for expand in self.request.GET.get("expand", "").split(",")
if expand
]
return expand if expand else None

View File

@@ -0,0 +1,75 @@
# Python imports
import requests
# Django imports
from django.conf import settings
from django.db.models import F
# Third party imports
from rest_framework import status
from rest_framework.response import Response
# Module imports
from .base import BaseAPIView
from plane.app.permissions.workspace import WorkSpaceAdminPermission
from plane.db.models import WorkspaceMember, Workspace
from plane.authentication.utils.host import base_host
class PaymentLinkEndpoint(BaseAPIView):
permission_classes = [
WorkSpaceAdminPermission,
]
def post(self, request, slug):
try:
workspace = Workspace.objects.get(slug=slug)
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")
)
for member in workspace_members:
member["user_id"] = str(member["user_id"])
product_id = request.data.get("product_id", False)
price_id = request.data.get("price_id", False)
if not product_id or not price_id:
return Response(
{"error": "product_id and price_id are required"},
status=status.HTTP_400_BAD_REQUEST,
)
if settings.PAYMENT_SERVER_BASE_URL:
response = requests.post(
f"{settings.PAYMENT_SERVER_BASE_URL}/api/payment-link/",
json={
"workspace_id": str(workspace.id),
"slug": slug,
"stripe_product_id": product_id,
"stripe_price_id": price_id,
"customer_email": request.user.email,
"members_list": list(workspace_members),
"host": base_host(request=request, is_app=True),
},
headers={"content-type": "application/json"},
)
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:
return Response(
{"error": "error fetching payment link"},
status=status.HTTP_400_BAD_REQUEST,
)

View File

@@ -0,0 +1,75 @@
# Python imports
import requests
# Django imports
from django.conf import settings
# Third party imports
from rest_framework import status
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
class ProductEndpoint(BaseAPIView):
permission_classes = [
WorkSpaceAdminPermission,
]
def get(self, request, slug):
try:
if settings.PAYMENT_SERVER_BASE_URL:
count = WorkspaceMember.objects.filter(
workspace__slug=slug
).count()
response = requests.get(
f"{settings.PAYMENT_SERVER_BASE_URL}/api/products/?quantity={count}",
headers={"content-type": "application/json"},
)
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,
)
except requests.exceptions.RequestException:
return Response(
{"error": "error fetching product details"},
status=status.HTTP_400_BAD_REQUEST,
)
class WorkspaceProductEndpoint(BaseAPIView):
permission_classes = [
WorkspaceUserPermission,
]
def get(self, request, slug):
try:
if settings.PAYMENT_SERVER_BASE_URL:
workspace = Workspace.objects.get(slug=slug)
response = requests.get(
f"{settings.PAYMENT_SERVER_BASE_URL}/api/products/workspace-products/{str(workspace.id)}/",
headers={"content-type": "application/json"},
)
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,
)
except requests.exceptions.RequestException:
return Response(
{"error": "error fetching product details"},
status=status.HTTP_400_BAD_REQUEST,
)

View File

@@ -46,6 +46,7 @@ INSTALLED_APPS = [
"plane.api",
"plane.authentication",
"plane.ee",
"plane.payment",
# Third-party things
"rest_framework",
"corsheaders",
@@ -357,3 +358,6 @@ CSRF_FAILURE_VIEW = "plane.authentication.views.common.csrf_failure"
ADMIN_BASE_URL = os.environ.get("ADMIN_BASE_URL", None)
SPACE_BASE_URL = os.environ.get("SPACE_BASE_URL", None)
APP_BASE_URL = os.environ.get("APP_BASE_URL")
# Cloud server base url
PAYMENT_SERVER_BASE_URL = os.environ.get("PAYMENT_SERVER_BASE_URL", False)

View File

View File

@@ -15,6 +15,7 @@ urlpatterns = [
path("api/instances/", include("plane.license.urls")),
path("api/v1/", include("plane.api.urls")),
path("auth/", include("plane.authentication.urls")),
path("api/payments/", include("plane.payment.urls")),
path("", include("plane.web.urls")),
]

View File

@@ -30,3 +30,4 @@ export * from "./pragmatic";
export * from "./publish";
// enterprise
export * from "./active-cycle";
export * from "./payment";

21
packages/types/src/payment.d.ts vendored Normal file
View File

@@ -0,0 +1,21 @@
export type IPaymentProductPrice = {
currency: string;
id: string;
product: string;
recurring: "month" | "year";
unit_amount: number;
workspace_amount: number;
};
export type IPaymentProduct = {
description: string;
id: string;
name: string;
type: "PRO" | "ULTIMATE";
prices: IPaymentProductPrice[];
};
export type IWorkspaceProductSubscription = {
product: FREE | PRO | ULTIMATE;
expiry_date: string | null;
};

View File

@@ -25,7 +25,8 @@
"NEXT_PUBLIC_SENTRY_DSN",
"SENTRY_MONITORING_ENABLED",
"NEXT_PUBLIC_PRO_PLAN_MONTHLY_REDIRECT_URL",
"NEXT_PUBLIC_PRO_PLAN_YEARLY_REDIRECT_URL"
"NEXT_PUBLIC_PRO_PLAN_YEARLY_REDIRECT_URL",
"NEXT_PUBLIC_IS_MULTI_TENANT"
],
"tasks": {
"build": {

View File

@@ -1,28 +1,46 @@
import React, { useState } from "react";
import { observer } from "mobx-react";
import Image from "next/image";
import { useRouter } from "next/router";
import useSWR from "swr";
// ui
import { Tooltip, Button, getButtonStyling } from "@plane/ui";
// components
// hooks
import { cn } from "@/helpers/common.helper";
import { useEventTracker, useInstance } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web components
import { PlaneOneModal, ProPlanModal } from "@/plane-web/components/license";
import { PlaneOneModal, CloudProductsModal, ProPlanDetailsModal } from "@/plane-web/components/license";
// assets
import PlaneOneLogo from "@/public/plane-logos/plane-one.svg";
// services
import { DiscoService } from "@/services/disco.service";
import packageJson from "package.json";
export const PlaneBadge: React.FC = () => {
const discoService = new DiscoService();
export const PlaneBadge: React.FC = observer(() => {
const router = useRouter();
const { workspaceSlug } = router.query;
// states
const [isProPlanModalOpen, setIsProPlanModalOpen] = useState(false);
const [isProPlanDetailsModalOpen, setProPlanDetailsModalOpen] = useState(false);
const [isPlaneOneModalOpen, setIsPlaneOneModalOpen] = useState(false);
// hooks
const { captureEvent } = useEventTracker();
const { isMobile } = usePlatformOS();
const { instance } = useInstance();
const handleProPlanModalOpen = () => {
// fetch workspace current plane information
const { data } = useSWR(
workspaceSlug ? "WORKSPACE_CURRENT_PLANE" : null,
workspaceSlug ? () => discoService.getWorkspaceCurrentPlane(workspaceSlug.toString()) : null
);
console.log("data", data);
const handleProPlanPurchaseModalOpen = () => {
setIsProPlanModalOpen(true);
captureEvent("pro_plan_modal_opened", {});
};
@@ -32,17 +50,31 @@ export const PlaneBadge: React.FC = () => {
captureEvent("plane_one_modal_opened", {});
};
if (process.env.NEXT_PUBLIC_PRO_PLAN_MONTHLY_REDIRECT_URL || process.env.NEXT_PUBLIC_PRO_PLAN_YEARLY_REDIRECT_URL) {
// const handleProPlanDetailsModalOpen = () => {
// setProPlanDetailsModalOpen(true);
// };
if (process.env.NEXT_PUBLIC_IS_MULTI_TENANT === "1") {
return (
<>
<ProPlanModal isOpen={isProPlanModalOpen} handleClose={() => setIsProPlanModalOpen(false)} />
<Button
variant="outline-primary"
className="w-1/2 cursor-pointer rounded-2xl px-3 py-1.5 text-center text-sm font-medium outline-none"
onClick={handleProPlanModalOpen}
>
Plane Pro
</Button>
<CloudProductsModal isOpen={isProPlanModalOpen} handleClose={() => setIsProPlanModalOpen(false)} />
<ProPlanDetailsModal isOpen={isProPlanDetailsModalOpen} handleClose={() => setProPlanDetailsModalOpen(false)} />
{data && data.product === "s" && (
<Button
variant="outline-primary"
className="w-1/2 cursor-pointer rounded-2xl px-3 py-1.5 text-center text-sm font-medium outline-none"
onClick={handleProPlanPurchaseModalOpen}
>
Upgrade to Pro
</Button>
)}
{data && data.product === "FREE" && (
<div className="w-1/2 flex justify-start">
<span className="items-center justify-center px-3.5 py-0.5 text-xs leading-4 rounded-xl bg-custom-primary-100/10 text-custom-primary-100">
Pro
</span>
</div>
)}
</>
);
}
@@ -77,4 +109,4 @@ export const PlaneBadge: React.FC = () => {
</Tooltip>
</>
);
};
});

View File

@@ -0,0 +1,35 @@
import { IPaymentProduct, IWorkspaceProductSubscription } from "@plane/types";
// helpers
import { API_BASE_URL } from "@/helpers/common.helper";
// services
import { APIService } from "@/services/api.service";
export class DiscoService extends APIService {
constructor() {
super(API_BASE_URL);
}
listProducts(workspaceSlug: string): Promise<IPaymentProduct[]> {
return this.get(`/api/payments/workspaces/${workspaceSlug}/products/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
getPaymentLink(workspaceSlug: string, data = {}) {
return this.post(`/api/payments/workspaces/${workspaceSlug}/payment-link/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
getWorkspaceCurrentPlane(workspaceSlug: string): Promise<IWorkspaceProductSubscription> {
return this.get(`/api/payments/workspaces/${workspaceSlug}/current-plan/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}

View File

@@ -0,0 +1,213 @@
import { FC, Fragment, useState } from "react";
import orderBy from "lodash/orderBy";
import { useRouter } from "next/router";
import useSWR from "swr";
// icons
import { CheckCircle } from "lucide-react";
// ui
import { Dialog, Transition, Tab } from "@headlessui/react";
// types
import { IPaymentProduct, IPaymentProductPrice } from "@plane/types";
// ui
import { setToast, TOAST_TYPE } from "@plane/ui";
// store
import { useEventTracker } from "@/hooks/store";
// services
import { DiscoService } from "@/services/disco.service";
const discoService = new DiscoService();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function classNames(...classes: any[]) {
return classes.filter(Boolean).join(" ");
}
const MONTHLY_PLAN_ITEMS = [
"White-glove onboarding for your use-cases",
"Bespoke implementation",
"Priority integrations",
"Priority Support and SLAs",
"Early access to all paid features",
"Locked-in discount for a whole year",
];
const YEARLY_PLAN_ITEMS = [
"White-glove onboarding for your use-cases",
"Bespoke implementation",
"Priority integrations",
"Priority Support and SLAs",
"Early access to all paid features",
"Tiered discounts for the second and third years",
];
export type CloudProductsModalProps = {
isOpen: boolean;
handleClose: () => void;
};
export const CloudProductsModal: FC<CloudProductsModalProps> = (props) => {
const { isOpen, handleClose } = props;
const router = useRouter();
const { workspaceSlug } = router.query;
// store
const { captureEvent } = useEventTracker();
// fetch products
const { data } = useSWR(
workspaceSlug ? "CLOUD_PAYMENT_PRODUCTS" : null,
workspaceSlug ? () => discoService.listProducts(workspaceSlug.toString()) : null
);
const proProduct = data?.find((product: IPaymentProduct) => product?.type === "PRO");
const proProductPrices = orderBy(proProduct?.prices || [], ["recurring"], ["asc"]);
console.log("data", data);
// states
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [tabIndex, setTabIndex] = useState(0);
const [isLoading, setLoading] = useState(false);
const handlePaymentLink = (priceId: string) => {
if (!workspaceSlug) return;
setLoading(true);
captureEvent("pro_plan_payment_link_clicked", { workspaceSlug });
discoService
.getPaymentLink(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");
}
})
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Failed to generate payment link. Please try again.",
});
})
.finally(() => {
setLoading(false);
});
};
const getPlaneFeatureItems = (recurringType: string) => {
if (recurringType === "month") {
return MONTHLY_PLAN_ITEMS;
}
if (recurringType === "year") {
return YEARLY_PLAN_ITEMS;
}
return [];
};
return (
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={handleClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-custom-backdrop" />
</Transition.Child>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="w-full max-w-lg transform overflow-hidden rounded-2xl bg-custom-background-100 p-6 text-left align-middle shadow-xl transition-all border-[0.5px] border-custom-border-100">
<Dialog.Title as="h2" className="text-2xl font-bold leading-6 mt-4 flex justify-center items-center">
Early-adopter pricing for believers
</Dialog.Title>
<div className="mt-2 mb-5">
<p className="text-center text-sm mb-6 px-10 text-custom-text-200">
Build Plane to your specs. You decide what we prioritize and build for everyone. Also get tailored
onboarding + implementation and priority support.
</p>
<Tab.Group>
<div className="flex w-full justify-center">
<Tab.List className="flex space-x-1 rounded-xl bg-custom-background-80 p-1 w-[72%]">
{proProductPrices.map((price: IPaymentProductPrice, index: number) => (
<Tab
key={price?.id}
className={({ selected }) =>
classNames(
"w-full rounded-lg py-2 text-sm font-medium leading-5",
"ring-white/60 ring-offset-2 ring-offset-custom-primary-90 focus:outline-none",
selected
? "bg-custom-background-100 text-custom-primary-100 shadow"
: "hover:bg-custom-background-80 text-custom-text-300 hover:text-custom-text-100"
)
}
onClick={() => setTabIndex(index)}
>
<>
{price.recurring === "month" && ("Monthly" as string)}
{price.recurring === "year" && ("Annual" as string)}
{price.recurring === "year" && (
<span className="bg-custom-primary-100 text-white rounded-full px-2 py-1 ml-1 text-xs">
-28%
</span>
)}
</>
</Tab>
))}
</Tab.List>
</div>
<Tab.Panels className="mt-2">
{proProductPrices?.map((price: IPaymentProductPrice, index: number) => (
<Tab.Panel key={index} className={classNames("rounded-xl bg-custom-background-100 p-3")}>
<p className="ml-4 text-4xl font-bold mb-2">
{price.recurring === "month" && "$7"}
{price.recurring === "year" && "$5"}
<span className="text-sm ml-3 text-custom-text-300">/user/month</span>
</p>
<ul>
{getPlaneFeatureItems(price.recurring).map((item) => (
<li key={item} className="relative rounded-md p-3 flex">
<p className="text-sm font-medium leading-5 flex items-center">
<CheckCircle className="h-4 w-4 mr-4" />
<span>{item}</span>
</p>
</li>
))}
</ul>
<div className="flex justify-center w-full">
<div className="relative inline-flex group mt-8">
<div className="absolute transition-all duration-1000 opacity-50 -inset-px bg-gradient-to-r from-[#44BCFF] via-[#FF44EC] to-[#FF675E] rounded-xl blur-lg group-hover:opacity-100 group-hover:-inset-1 group-hover:duration-200 animate-tilt" />
<button
type="button"
className="relative inline-flex items-center justify-center px-8 py-4 text-sm font-medium border-custom-border-100 border-[1.5px] transition-all duration-200 bg-custom-background-100 rounded-xl focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-custom-border-200"
onClick={() => handlePaymentLink(price.id)}
disabled={isLoading}
>
Become Early Adopter
</button>
</div>
</div>
</Tab.Panel>
))}
</Tab.Panels>
</Tab.Group>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition>
);
};

View File

@@ -1,4 +1,5 @@
export * from "./pro-plan-modal";
export * from "./cloud-products-modal";
export * from "./plane-one-modal";
export * from "./plane-one-billing";
export * from "./plane-cloud-billing";
export * from "./pro-plan-details-modal";

View File

@@ -0,0 +1,55 @@
import { FC, Fragment } from "react";
// ui
import { Dialog, Transition } from "@headlessui/react";
export type ProPlanDetailsModalProps = {
isOpen: boolean;
handleClose: () => void;
};
export const ProPlanDetailsModal: FC<ProPlanDetailsModalProps> = (props) => {
const { isOpen, handleClose } = props;
return (
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={handleClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-custom-backdrop" />
</Transition.Child>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="w-full max-w-lg transform overflow-hidden rounded-2xl bg-custom-background-100 p-6 text-left align-middle shadow-xl transition-all border-[0.5px] border-custom-border-100">
<Dialog.Title as="h2" className="text-2xl font-bold leading-6 mt-4 flex justify-center items-center">
Thank you for being an early adopter
</Dialog.Title>
<div className="mt-2 mb-5">
<p className="text-center text-sm mb-6 px-10 text-custom-text-200">
The wait will be worth it! Were excited to announce that our pro features will be rolling out
shortly. Billing will commence from the day these features become available.
</p>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition>
);
};

View File

@@ -1,6 +1,8 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export const DISCO_BASE_URL = process.env.NEXT_PUBLIC_DISCO_BASE_URL || "";
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || "";
export const ADMIN_BASE_URL = process.env.NEXT_PUBLIC_ADMIN_BASE_URL || "";

View File

@@ -67,6 +67,7 @@ const nextConfig = {
destination: `${posthogHost}/:path*`,
},
];
if (process.env.NEXT_PUBLIC_ADMIN_BASE_URL || process.env.NEXT_PUBLIC_ADMIN_BASE_PATH) {
const ADMIN_BASE_URL = process.env.NEXT_PUBLIC_ADMIN_BASE_URL || "";
const ADMIN_BASE_PATH = process.env.NEXT_PUBLIC_ADMIN_BASE_PATH || "";