mirror of
https://github.com/makeplane/plane.git
synced 2025-12-28 16:06:33 +01:00
fix: pro plane fixes
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
0
apiserver/plane/enterprise/__init__.py
Normal file
0
apiserver/plane/enterprise/__init__.py
Normal file
5
apiserver/plane/enterprise/apps.py
Normal file
5
apiserver/plane/enterprise/apps.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ApiConfig(AppConfig):
|
||||
name = "plane.enterprise"
|
||||
0
apiserver/plane/enterprise/views/__init__.py
Normal file
0
apiserver/plane/enterprise/views/__init__.py
Normal file
12
apiserver/plane/enterprise/views/product.py
Normal file
12
apiserver/plane/enterprise/views/product.py
Normal 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")
|
||||
0
apiserver/plane/payment/__init__.py
Normal file
0
apiserver/plane/payment/__init__.py
Normal file
5
apiserver/plane/payment/apps.py
Normal file
5
apiserver/plane/payment/apps.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ApiConfig(AppConfig):
|
||||
name = "plane.payment"
|
||||
0
apiserver/plane/payment/bgtasks/__init__.py
Normal file
0
apiserver/plane/payment/bgtasks/__init__.py
Normal file
57
apiserver/plane/payment/bgtasks/member_sync_task.py
Normal file
57
apiserver/plane/payment/bgtasks/member_sync_task.py
Normal 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
|
||||
25
apiserver/plane/payment/urls.py
Normal file
25
apiserver/plane/payment/urls.py
Normal 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",
|
||||
),
|
||||
]
|
||||
2
apiserver/plane/payment/views/__init__.py
Normal file
2
apiserver/plane/payment/views/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from .product import ProductEndpoint, WorkspaceProductEndpoint
|
||||
from .payment import PaymentLinkEndpoint
|
||||
132
apiserver/plane/payment/views/base.py
Normal file
132
apiserver/plane/payment/views/base.py
Normal 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
|
||||
75
apiserver/plane/payment/views/payment.py
Normal file
75
apiserver/plane/payment/views/payment.py
Normal 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,
|
||||
)
|
||||
75
apiserver/plane/payment/views/product.py
Normal file
75
apiserver/plane/payment/views/product.py
Normal 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,
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
0
apiserver/plane/settings/enterprise.py
Normal file
0
apiserver/plane/settings/enterprise.py
Normal 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")),
|
||||
]
|
||||
|
||||
|
||||
1
packages/types/src/index.d.ts
vendored
1
packages/types/src/index.d.ts
vendored
@@ -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
21
packages/types/src/payment.d.ts
vendored
Normal 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;
|
||||
};
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
35
web/core/services/disco.service.ts
Normal file
35
web/core/services/disco.service.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
213
web/ee/components/license/cloud-products-modal.tsx
Normal file
213
web/ee/components/license/cloud-products-modal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
55
web/ee/components/license/pro-plan-details-modal.tsx
Normal file
55
web/ee/components/license/pro-plan-details-modal.tsx
Normal 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! We’re 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>
|
||||
);
|
||||
};
|
||||
@@ -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 || "";
|
||||
|
||||
@@ -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 || "";
|
||||
|
||||
Reference in New Issue
Block a user