mirror of
https://github.com/makeplane/plane.git
synced 2025-12-28 16:06:33 +01:00
dev: feature flagging implementation (#443)
* dev: initialize feature flagging * dev: feature flagging workspace active cycles * dev: update feature flag implementation * dev: add `FEATURE_FLAG_SERVER_AUTH_TOKEN` env * dev: add feature flagging for backend apis * dev: setup feature flags store and hooks. (#558) * dev: setup feature flags store and hooks. * minor improvements for swr key and flags enum. * dev: workspace active cycles feature flag. (#562) * dev: add task for cancelling the workspace subscription when the workspace is deleted * dev: rename feaure flagging component * dev: update feature flagging function for spaces * dev: add feature flags for bulk ops, issue embeds and page publish. (#589) * dev: add logging for member sync task * dev: restrict workspace from deleting if the subscription is active * dev: workspace delete check endpoint * dev: subscription endpoint check * dev: update subscriptions * chore: plane pro billing and plans page updates. * dev: update pro pill display logic. * dev: fix feature flagging * chore: minor improvement in cloud-badge to avoid API calls to `products` endpoint if user has PRO subscription. --------- Co-authored-by: Prateek Shourya <prateekshourya29@gmail.com> Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
This commit is contained in:
committed by
sriram veeraghanta
parent
ed567f77e6
commit
4e8bfe0024
@@ -2,8 +2,10 @@
|
||||
import csv
|
||||
import io
|
||||
from datetime import date
|
||||
|
||||
import requests
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
# Django imports
|
||||
from django.db import IntegrityError
|
||||
from django.db.models import (
|
||||
Count,
|
||||
@@ -15,8 +17,6 @@ from django.db.models import (
|
||||
)
|
||||
from django.db.models.fields import DateField
|
||||
from django.db.models.functions import Cast, ExtractDay, ExtractWeek
|
||||
|
||||
# Django imports
|
||||
from django.http import HttpResponse
|
||||
from django.utils import timezone
|
||||
|
||||
@@ -49,6 +49,7 @@ from django.views.decorators.cache import cache_control
|
||||
from django.views.decorators.vary import vary_on_cookie
|
||||
from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
|
||||
from plane.payment.bgtasks.member_sync_task import member_sync_task
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class WorkSpaceViewSet(BaseViewSet):
|
||||
@@ -167,6 +168,33 @@ class WorkSpaceViewSet(BaseViewSet):
|
||||
path="/api/users/me/settings/", multiple=True, user=False
|
||||
)
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
# Get the workspace
|
||||
workspace = self.get_object()
|
||||
|
||||
# Fetch the workspace subcription
|
||||
if settings.PAYMENT_SERVER_BASE_URL:
|
||||
# Make a cancel request to the payment server
|
||||
response = requests.post(
|
||||
f"{settings.PAYMENT_SERVER_BASE_URL}/api/subscriptions/check/",
|
||||
headers={
|
||||
"content-type": "application/json",
|
||||
"x-api-key": settings.PAYMENT_SERVER_AUTH_TOKEN,
|
||||
},
|
||||
json={"workspace_id": str(workspace.id)},
|
||||
)
|
||||
# Check if the response is successful
|
||||
response.raise_for_status()
|
||||
# Return the response
|
||||
response = response.json()
|
||||
# Check if the response contains the product key
|
||||
if response.get("subscription_exists"):
|
||||
return Response(
|
||||
{"error": "workspace has active subscription"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
else:
|
||||
# Delete the workspace
|
||||
return super().destroy(request, *args, **kwargs)
|
||||
return super().destroy(request, *args, **kwargs)
|
||||
|
||||
|
||||
|
||||
@@ -146,6 +146,8 @@ class WorkSpaceMemberViewSet(BaseViewSet):
|
||||
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
# Sync workspace members
|
||||
member_sync_task.delay(slug)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Django imports
|
||||
from django.urls import path
|
||||
|
||||
from plane.ee.views import RephraseGrammarEndpoint
|
||||
from plane.ee.views.app import RephraseGrammarEndpoint
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from django.urls import path
|
||||
|
||||
from plane.ee.views import (
|
||||
from plane.ee.views.app import (
|
||||
WorkspaceActiveCycleEndpoint,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
from django.urls import path
|
||||
|
||||
from plane.ee.views import (
|
||||
from plane.ee.views.app import (
|
||||
BulkIssueOperationsEndpoint,
|
||||
BulkArchiveIssuesEndpoint,
|
||||
BulkSubscribeIssuesEndpoint
|
||||
BulkSubscribeIssuesEndpoint,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/bulk-operation-issues/",
|
||||
BulkIssueOperationsEndpoint.as_view(),
|
||||
name="bulk-operations-issues",
|
||||
),
|
||||
path(
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/bulk-archive-issues/",
|
||||
BulkArchiveIssuesEndpoint.as_view(),
|
||||
name="bulk-archive-issues",
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
from plane.ee.views.app.ai import RephraseGrammarEndpoint
|
||||
from plane.ee.views.app.cycle import WorkspaceActiveCycleEndpoint
|
||||
from plane.ee.views.app.issue import (
|
||||
BulkIssueOperationsEndpoint,
|
||||
BulkArchiveIssuesEndpoint,
|
||||
BulkSubscribeIssuesEndpoint,
|
||||
)
|
||||
|
||||
@@ -21,6 +21,10 @@ from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import Cycle, UserFavorite, Issue, Label, User, Project
|
||||
from plane.utils.analytics_plot import burndown_plot
|
||||
|
||||
# ee imports
|
||||
from plane.ee.views.base import BaseAPIView
|
||||
from plane.ee.permissions import (
|
||||
WorkspaceUserPermission,
|
||||
@@ -28,8 +32,8 @@ from plane.ee.permissions import (
|
||||
from plane.ee.serializers import (
|
||||
WorkspaceActiveCycleSerializer,
|
||||
)
|
||||
from plane.db.models import Cycle, UserFavorite, Issue, Label, User, Project
|
||||
from plane.utils.analytics_plot import burndown_plot
|
||||
from plane.payment.flags.flag_decorator import check_feature_flag
|
||||
from plane.payment.flags.flag import FeatureFlag
|
||||
|
||||
|
||||
class WorkspaceActiveCycleEndpoint(BaseAPIView):
|
||||
@@ -232,6 +236,7 @@ class WorkspaceActiveCycleEndpoint(BaseAPIView):
|
||||
)
|
||||
return results
|
||||
|
||||
@check_feature_flag(FeatureFlag.WORKSPACE_ACTIVE_CYCLES)
|
||||
def get(self, request, slug):
|
||||
|
||||
favorite_subquery = UserFavorite.objects.filter(
|
||||
|
||||
@@ -22,10 +22,12 @@ from plane.db.models import (
|
||||
IssueLabel,
|
||||
IssueAssignee,
|
||||
Workspace,
|
||||
IssueSubscriber
|
||||
IssueSubscriber,
|
||||
)
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.ee.bgtasks import bulk_issue_activity
|
||||
from plane.payment.flags.flag_decorator import check_feature_flag
|
||||
from plane.payment.flags.flag import FeatureFlag
|
||||
|
||||
|
||||
class BulkIssueOperationsEndpoint(BaseAPIView):
|
||||
@@ -33,6 +35,7 @@ class BulkIssueOperationsEndpoint(BaseAPIView):
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
@check_feature_flag(FeatureFlag.BULK_OPS)
|
||||
def post(self, request, slug, project_id):
|
||||
issue_ids = request.data.get("issue_ids", [])
|
||||
if not len(issue_ids):
|
||||
@@ -271,7 +274,13 @@ class BulkIssueOperationsEndpoint(BaseAPIView):
|
||||
# Bulk update all the objects
|
||||
Issue.objects.bulk_update(
|
||||
bulk_update_issues,
|
||||
["priority", "start_date", "target_date", "state_id", "completed_at"],
|
||||
[
|
||||
"priority",
|
||||
"start_date",
|
||||
"target_date",
|
||||
"state_id",
|
||||
"completed_at",
|
||||
],
|
||||
batch_size=100,
|
||||
)
|
||||
|
||||
@@ -303,6 +312,7 @@ class BulkArchiveIssuesEndpoint(BaseAPIView):
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
@check_feature_flag(FeatureFlag.BULK_OPS)
|
||||
def post(self, request, slug, project_id):
|
||||
issue_ids = request.data.get("issue_ids", [])
|
||||
|
||||
@@ -358,6 +368,7 @@ class BulkSubscribeIssuesEndpoint(BaseAPIView):
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
@check_feature_flag(FeatureFlag.BULK_OPS)
|
||||
def post(self, request, slug, project_id):
|
||||
issue_ids = request.data.get("issue_ids", [])
|
||||
workspace = Workspace.objects.filter(slug=slug).first()
|
||||
|
||||
@@ -10,6 +10,8 @@ from plane.ee.permissions import (
|
||||
)
|
||||
from plane.db.models import DeployBoard, Workspace, Page
|
||||
from plane.app.serializers import DeployBoardSerializer
|
||||
from plane.payment.flags.flag_decorator import check_feature_flag
|
||||
from plane.payment.flags.flag import FeatureFlag
|
||||
|
||||
|
||||
class ProjectPagePublishEndpoint(BaseAPIView):
|
||||
@@ -18,6 +20,7 @@ class ProjectPagePublishEndpoint(BaseAPIView):
|
||||
ProjectMemberPermission,
|
||||
]
|
||||
|
||||
@check_feature_flag(FeatureFlag.PAGE_PUBLISH)
|
||||
def post(self, request, slug, project_id, page_id):
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
# Fetch the page
|
||||
@@ -62,6 +65,7 @@ class ProjectPagePublishEndpoint(BaseAPIView):
|
||||
serializer = DeployBoardSerializer(deploy_board)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@check_feature_flag(FeatureFlag.PAGE_PUBLISH)
|
||||
def patch(self, request, slug, project_id, page_id):
|
||||
# Get the deploy board
|
||||
deploy_board = DeployBoard.objects.get(
|
||||
@@ -96,6 +100,7 @@ class ProjectPagePublishEndpoint(BaseAPIView):
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@check_feature_flag(FeatureFlag.PAGE_PUBLISH)
|
||||
def get(self, request, slug, project_id, page_id):
|
||||
# Get the deploy board
|
||||
deploy_board = DeployBoard.objects.get(
|
||||
@@ -123,6 +128,7 @@ class WorkspacePagePublishEndpoint(BaseAPIView):
|
||||
WorkSpaceAdminPermission,
|
||||
]
|
||||
|
||||
@check_feature_flag(FeatureFlag.PAGE_PUBLISH)
|
||||
def post(self, request, slug, page_id):
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
# Fetch the page
|
||||
@@ -165,6 +171,7 @@ class WorkspacePagePublishEndpoint(BaseAPIView):
|
||||
serializer = DeployBoardSerializer(deploy_board)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@check_feature_flag(FeatureFlag.PAGE_PUBLISH)
|
||||
def patch(self, request, slug, page_id):
|
||||
deploy_board = DeployBoard.objects.get(
|
||||
entity_identifier=page_id, entity_name="page", workspace__slug=slug
|
||||
@@ -194,6 +201,7 @@ class WorkspacePagePublishEndpoint(BaseAPIView):
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@check_feature_flag(FeatureFlag.PAGE_PUBLISH)
|
||||
def get(self, request, slug, page_id):
|
||||
deploy_board = DeployBoard.objects.get(
|
||||
entity_identifier=page_id, entity_name="page", workspace__slug=slug
|
||||
|
||||
@@ -31,6 +31,8 @@ from plane.db.models import (
|
||||
from plane.ee.views.base import BaseViewSet
|
||||
|
||||
from plane.bgtasks.page_transaction_task import page_transaction
|
||||
from plane.payment.flags.flag_decorator import check_feature_flag
|
||||
from plane.payment.flags.flag import FeatureFlag
|
||||
|
||||
|
||||
def unarchive_archive_page_and_descendants(page_id, archived_at):
|
||||
@@ -90,6 +92,7 @@ class WorkspacePageViewSet(BaseViewSet):
|
||||
.distinct()
|
||||
)
|
||||
|
||||
@check_feature_flag(FeatureFlag.WORKSPACE_PAGES)
|
||||
def create(self, request, slug):
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
serializer = WorkspacePageSerializer(
|
||||
@@ -112,6 +115,7 @@ class WorkspacePageViewSet(BaseViewSet):
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@check_feature_flag(FeatureFlag.WORKSPACE_PAGES)
|
||||
def partial_update(self, request, slug, pk):
|
||||
try:
|
||||
page = Page.objects.get(
|
||||
@@ -175,6 +179,7 @@ class WorkspacePageViewSet(BaseViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@check_feature_flag(FeatureFlag.WORKSPACE_PAGES)
|
||||
def retrieve(self, request, slug, pk=None):
|
||||
page = self.get_queryset().filter(pk=pk).first()
|
||||
if page is None:
|
||||
@@ -188,6 +193,7 @@ class WorkspacePageViewSet(BaseViewSet):
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@check_feature_flag(FeatureFlag.WORKSPACE_PAGES)
|
||||
def lock(self, request, slug, pk):
|
||||
page = Page.objects.filter(
|
||||
pk=pk,
|
||||
@@ -198,6 +204,7 @@ class WorkspacePageViewSet(BaseViewSet):
|
||||
page.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@check_feature_flag(FeatureFlag.WORKSPACE_PAGES)
|
||||
def unlock(self, request, slug, pk):
|
||||
page = Page.objects.filter(
|
||||
pk=pk,
|
||||
@@ -209,11 +216,13 @@ class WorkspacePageViewSet(BaseViewSet):
|
||||
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@check_feature_flag(FeatureFlag.WORKSPACE_PAGES)
|
||||
def list(self, request, slug):
|
||||
queryset = self.get_queryset()
|
||||
pages = WorkspacePageSerializer(queryset, many=True).data
|
||||
return Response(pages, status=status.HTTP_200_OK)
|
||||
|
||||
@check_feature_flag(FeatureFlag.WORKSPACE_PAGES)
|
||||
def archive(self, request, slug, pk):
|
||||
page = Page.objects.get(
|
||||
pk=pk,
|
||||
@@ -241,6 +250,7 @@ class WorkspacePageViewSet(BaseViewSet):
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@check_feature_flag(FeatureFlag.WORKSPACE_PAGES)
|
||||
def unarchive(self, request, slug, pk):
|
||||
page = Page.objects.get(
|
||||
pk=pk,
|
||||
@@ -270,6 +280,7 @@ class WorkspacePageViewSet(BaseViewSet):
|
||||
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@check_feature_flag(FeatureFlag.WORKSPACE_PAGES)
|
||||
def destroy(self, request, slug, pk):
|
||||
page = Page.objects.get(
|
||||
pk=pk,
|
||||
@@ -310,6 +321,7 @@ class WorkspacePagesDescriptionViewSet(BaseViewSet):
|
||||
WorkspaceEntityPermission,
|
||||
]
|
||||
|
||||
@check_feature_flag(FeatureFlag.WORKSPACE_PAGES)
|
||||
def retrieve(self, request, slug, pk):
|
||||
page = Page.objects.get(
|
||||
pk=pk,
|
||||
|
||||
@@ -13,6 +13,8 @@ from plane.db.models import (
|
||||
IssueView,
|
||||
)
|
||||
from plane.ee.views.base import BaseViewSet
|
||||
from plane.payment.flags.flag_decorator import check_feature_flag
|
||||
from plane.payment.flags.flag import FeatureFlag
|
||||
|
||||
|
||||
class IssueViewEEViewSet(BaseViewSet):
|
||||
@@ -41,6 +43,7 @@ class IssueViewEEViewSet(BaseViewSet):
|
||||
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@check_feature_flag(FeatureFlag.VIEW_ACCESS_PRIVATE)
|
||||
def access(self, request, slug, project_id, pk):
|
||||
access = request.data.get("access", 1)
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ from plane.ee.permissions import (
|
||||
)
|
||||
from plane.db.models import DeployBoard, Workspace, IssueView
|
||||
from plane.app.serializers import DeployBoardSerializer
|
||||
from plane.payment.flags.flag_decorator import check_feature_flag
|
||||
from plane.payment.flags.flag import FeatureFlag
|
||||
|
||||
|
||||
class WorkspaceViewsPublishEndpoint(BaseAPIView):
|
||||
@@ -18,6 +20,7 @@ class WorkspaceViewsPublishEndpoint(BaseAPIView):
|
||||
WorkSpaceAdminPermission,
|
||||
]
|
||||
|
||||
@check_feature_flag(FeatureFlag.VIEW_PUBLISH)
|
||||
def post(self, request, slug, view_id):
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
# Fetch the view
|
||||
@@ -63,6 +66,7 @@ class WorkspaceViewsPublishEndpoint(BaseAPIView):
|
||||
serializer = DeployBoardSerializer(deploy_board)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@check_feature_flag(FeatureFlag.VIEW_PUBLISH)
|
||||
def patch(self, request, slug, view_id):
|
||||
deploy_board = DeployBoard.objects.get(
|
||||
entity_identifier=view_id, entity_name="view", workspace__slug=slug
|
||||
@@ -92,6 +96,7 @@ class WorkspaceViewsPublishEndpoint(BaseAPIView):
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@check_feature_flag(FeatureFlag.VIEW_PUBLISH)
|
||||
def get(self, request, slug, view_id):
|
||||
deploy_board = DeployBoard.objects.get(
|
||||
entity_identifier=view_id, entity_name="view", workspace__slug=slug
|
||||
@@ -99,6 +104,7 @@ class WorkspaceViewsPublishEndpoint(BaseAPIView):
|
||||
serializer = DeployBoardSerializer(deploy_board)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@check_feature_flag(FeatureFlag.VIEW_PUBLISH)
|
||||
def delete(self, request, slug, view_id):
|
||||
deploy_board = DeployBoard.objects.get(
|
||||
entity_identifier=view_id, entity_name="view", workspace__slug=slug
|
||||
@@ -113,6 +119,7 @@ class IssueViewsPublishEndpoint(BaseAPIView):
|
||||
ProjectMemberPermission,
|
||||
]
|
||||
|
||||
@check_feature_flag(FeatureFlag.VIEW_PUBLISH)
|
||||
def post(self, request, slug, project_id, view_id):
|
||||
# Fetch the view
|
||||
issue_view = IssueView.objects.get(
|
||||
@@ -157,6 +164,7 @@ class IssueViewsPublishEndpoint(BaseAPIView):
|
||||
serializer = DeployBoardSerializer(deploy_board)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@check_feature_flag(FeatureFlag.VIEW_PUBLISH)
|
||||
def patch(self, request, slug, project_id, view_id):
|
||||
deploy_board = DeployBoard.objects.get(
|
||||
entity_identifier=view_id,
|
||||
@@ -189,6 +197,7 @@ class IssueViewsPublishEndpoint(BaseAPIView):
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@check_feature_flag(FeatureFlag.VIEW_PUBLISH)
|
||||
def get(self, request, slug, project_id, view_id):
|
||||
deploy_board = DeployBoard.objects.get(
|
||||
entity_identifier=view_id,
|
||||
@@ -199,6 +208,7 @@ class IssueViewsPublishEndpoint(BaseAPIView):
|
||||
serializer = DeployBoardSerializer(deploy_board)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@check_feature_flag(FeatureFlag.VIEW_PUBLISH)
|
||||
def delete(self, request, slug, project_id, view_id):
|
||||
deploy_board = DeployBoard.objects.get(
|
||||
entity_identifier=view_id,
|
||||
|
||||
@@ -13,6 +13,8 @@ from plane.db.models import (
|
||||
IssueView,
|
||||
)
|
||||
from plane.ee.views.base import BaseViewSet
|
||||
from plane.payment.flags.flag_decorator import check_feature_flag
|
||||
from plane.payment.flags.flag import FeatureFlag
|
||||
|
||||
|
||||
class WorkspaceViewEEViewSet(BaseViewSet):
|
||||
@@ -53,6 +55,7 @@ class WorkspaceViewEEViewSet(BaseViewSet):
|
||||
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@check_feature_flag(FeatureFlag.VIEW_ACCESS_PRIVATE)
|
||||
def access(self, request, slug, pk):
|
||||
access = request.data.get("access", 1)
|
||||
|
||||
|
||||
@@ -21,6 +21,9 @@ from plane.ee.serializers import (
|
||||
from plane.app.serializers import (
|
||||
IssuePublicSerializer,
|
||||
)
|
||||
from plane.payment.flags.flag_decorator import check_workspace_feature_flag
|
||||
from plane.payment.flags.flag import FeatureFlag
|
||||
from plane.payment.flags.flag_decorator import ErrorCodes
|
||||
|
||||
|
||||
class PagePublicEndpoint(BaseAPIView):
|
||||
@@ -34,10 +37,24 @@ class PagePublicEndpoint(BaseAPIView):
|
||||
deploy_board = DeployBoard.objects.get(
|
||||
anchor=anchor, entity_name="page"
|
||||
)
|
||||
# Get the page object
|
||||
page = Page.objects.get(pk=deploy_board.entity_identifier)
|
||||
serializer = PagePublicSerializer(page)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
# Check if the workspace has access to feature
|
||||
if check_workspace_feature_flag(
|
||||
feature_key=FeatureFlag.PAGE_PUBLISH,
|
||||
slug=deploy_board.workspace.slug,
|
||||
):
|
||||
# Get the page object
|
||||
page = Page.objects.get(pk=deploy_board.entity_identifier)
|
||||
serializer = PagePublicSerializer(page)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
else:
|
||||
return Response(
|
||||
{
|
||||
"error": "Payment required",
|
||||
"error_code": ErrorCodes.PAYMENT_REQUIRED.value,
|
||||
},
|
||||
status=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
)
|
||||
|
||||
|
||||
class PagePublicIssuesEndpoint(BaseAPIView):
|
||||
@@ -52,35 +69,48 @@ class PagePublicIssuesEndpoint(BaseAPIView):
|
||||
anchor=anchor, entity_name="page"
|
||||
)
|
||||
|
||||
# Get the issue's embedded inside the page
|
||||
page_issues = PageLog.objects.filter(
|
||||
page_id=deploy_board.entity_identifier, entity_name="issue"
|
||||
).values_list("entity_identifier", flat=True)
|
||||
if check_workspace_feature_flag(
|
||||
feature_key=FeatureFlag.PAGE_PUBLISH,
|
||||
slug=deploy_board.workspace.slug,
|
||||
):
|
||||
|
||||
issue_queryset = (
|
||||
Issue.issue_objects.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(
|
||||
parent=OuterRef("id")
|
||||
# Get the issue's embedded inside the page
|
||||
page_issues = PageLog.objects.filter(
|
||||
page_id=deploy_board.entity_identifier, entity_name="issue"
|
||||
).values_list("entity_identifier", flat=True)
|
||||
|
||||
issue_queryset = (
|
||||
Issue.issue_objects.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(
|
||||
parent=OuterRef("id")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.filter(pk__in=page_issues)
|
||||
.select_related("project", "workspace", "state", "parent")
|
||||
.prefetch_related("assignees", "labels")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_reactions",
|
||||
queryset=IssueReaction.objects.select_related("actor"),
|
||||
.filter(pk__in=page_issues)
|
||||
.select_related("project", "workspace", "state", "parent")
|
||||
.prefetch_related("assignees", "labels")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_reactions",
|
||||
queryset=IssueReaction.objects.select_related("actor"),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"votes",
|
||||
queryset=IssueVote.objects.select_related("actor"),
|
||||
)
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"votes",
|
||||
queryset=IssueVote.objects.select_related("actor"),
|
||||
)
|
||||
serializer = IssuePublicSerializer(issue_queryset, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
else:
|
||||
return Response(
|
||||
{
|
||||
"error": "Payment required",
|
||||
"error_code": ErrorCodes.PAYMENT_REQUIRED.value,
|
||||
},
|
||||
status=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
)
|
||||
)
|
||||
serializer = IssuePublicSerializer(issue_queryset, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -12,6 +12,9 @@ from plane.db.models import (
|
||||
from plane.ee.serializers import (
|
||||
ViewsPublicSerializer,
|
||||
)
|
||||
from plane.payment.flags.flag_decorator import check_workspace_feature_flag
|
||||
from plane.payment.flags.flag import FeatureFlag
|
||||
from plane.payment.flags.flag_decorator import ErrorCodes
|
||||
|
||||
|
||||
class ViewsPublicEndpoint(BaseAPIView):
|
||||
@@ -25,7 +28,22 @@ class ViewsPublicEndpoint(BaseAPIView):
|
||||
deploy_board = DeployBoard.objects.get(
|
||||
anchor=anchor, entity_name="view"
|
||||
)
|
||||
# Get the views object
|
||||
views = IssueView.objects.get(pk=deploy_board.entity_identifier)
|
||||
serializer = ViewsPublicSerializer(views)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
# Check if the workspace has access to feature
|
||||
if check_workspace_feature_flag(
|
||||
feature_key=FeatureFlag.PAGE_PUBLISH,
|
||||
slug=deploy_board.workspace.slug,
|
||||
):
|
||||
# Get the views object
|
||||
views = IssueView.objects.get(pk=deploy_board.entity_identifier)
|
||||
serializer = ViewsPublicSerializer(views)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
# Check if the workspace has access to feature
|
||||
else:
|
||||
return Response(
|
||||
{
|
||||
"error": "Payment required",
|
||||
"error_code": ErrorCodes.PAYMENT_REQUIRED.value,
|
||||
},
|
||||
status=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
)
|
||||
|
||||
@@ -10,48 +10,65 @@ from celery import shared_task
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import WorkspaceMember, Workspace
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
@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)
|
||||
try:
|
||||
# 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
|
||||
# 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"),
|
||||
user_role=F("role"),
|
||||
)
|
||||
.values("user_email", "user_id", "user_role")
|
||||
)
|
||||
.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"])
|
||||
# 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"},
|
||||
)
|
||||
# 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",
|
||||
"x-api-key": settings.PAYMENT_SERVER_AUTH_TOKEN,
|
||||
},
|
||||
)
|
||||
|
||||
# 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
|
||||
# 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
|
||||
else:
|
||||
except requests.exceptions.RequestException as e:
|
||||
log_exception(e)
|
||||
return
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return
|
||||
|
||||
30
apiserver/plane/payment/flags/flag.py
Normal file
30
apiserver/plane/payment/flags/flag.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class FeatureFlag(Enum):
|
||||
# Workspace level active cycles
|
||||
WORKSPACE_ACTIVE_CYCLES = "WORKSPACE_ACTIVE_CYCLES"
|
||||
# Bulk operations on issues
|
||||
BULK_OPS = "BULK_OPS"
|
||||
# Make views public or private
|
||||
VIEW_ACCESS_PRIVATE = "VIEW_ACCESS_PRIVATE"
|
||||
# View publish
|
||||
VIEW_PUBLISH = "VIEW_PUBLISH"
|
||||
# View Locking and unlocking
|
||||
VIEW_LOCKING = "VIEW_LOCKING"
|
||||
# Workspace level pages
|
||||
WORKSPACE_PAGES = "WORKSPACE_PAGES"
|
||||
# OIDC SAML Auth
|
||||
OIDC_SAML_AUTH = "OIDC_SAML_AUTH"
|
||||
# Page level issue embeds
|
||||
PAGE_ISSUE_EMBEDS = "PAGE_ISSUE_EMBEDS"
|
||||
# Page Publish
|
||||
PAGE_PUBLISH = "PAGE_PUBLISH"
|
||||
# Work logging
|
||||
WORK_LOG = "WORK_LOG"
|
||||
# Estimate with time
|
||||
ESTIMATE_WITH_TIME = "ESTIMATE_WITH_TIME"
|
||||
# Issue list
|
||||
ISSUE_TYPE_DISPLAY = "ISSUE_TYPE_DISPLAY"
|
||||
# Settings
|
||||
ISSUE_TYPE_SETTINGS = "ISSUE_TYPE_SETTINGS"
|
||||
73
apiserver/plane/payment/flags/flag_decorator.py
Normal file
73
apiserver/plane/payment/flags/flag_decorator.py
Normal file
@@ -0,0 +1,73 @@
|
||||
# Python imports
|
||||
from functools import wraps
|
||||
from enum import Enum
|
||||
|
||||
# Django imports
|
||||
|
||||
# Third party imports
|
||||
import openfeature.api
|
||||
from openfeature.evaluation_context import EvaluationContext
|
||||
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
# Module imports
|
||||
from .provider import FlagProvider
|
||||
|
||||
|
||||
class ErrorCodes(Enum):
|
||||
PAYMENT_REQUIRED = 1999
|
||||
|
||||
|
||||
def check_feature_flag(feature_key, default_value=True):
|
||||
"""decorator to feature flag"""
|
||||
|
||||
def decorator(view_func):
|
||||
@wraps(view_func)
|
||||
def _wrapped_view(instance, request, *args, **kwargs):
|
||||
# Function to generate cache key
|
||||
openfeature.api.set_provider(FlagProvider())
|
||||
client = openfeature.api.get_client()
|
||||
if client.get_boolean_value(
|
||||
flag_key=feature_key.value,
|
||||
default_value=default_value,
|
||||
evaluation_context=EvaluationContext(
|
||||
str(request.user.id),
|
||||
{"slug": kwargs.get("slug")},
|
||||
),
|
||||
):
|
||||
response = view_func(instance, request, *args, **kwargs)
|
||||
else:
|
||||
response = Response(
|
||||
{
|
||||
"error": "Payment required",
|
||||
"error_code": ErrorCodes.PAYMENT_REQUIRED.value,
|
||||
},
|
||||
status=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
)
|
||||
return response
|
||||
|
||||
return _wrapped_view
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def check_workspace_feature_flag(
|
||||
feature_key, slug, user_id=None, default_value=True
|
||||
):
|
||||
"""Function to check workspace feature flag"""
|
||||
# Function to generate cache key
|
||||
openfeature.api.set_provider(FlagProvider())
|
||||
client = openfeature.api.get_client()
|
||||
|
||||
# Function to check if the feature flag is enabled
|
||||
flag = client.get_boolean_value(
|
||||
flag_key=feature_key.value,
|
||||
default_value=default_value,
|
||||
evaluation_context=EvaluationContext(
|
||||
user_id,
|
||||
{"slug": slug},
|
||||
),
|
||||
)
|
||||
# Return the flag
|
||||
return flag
|
||||
98
apiserver/plane/payment/flags/provider.py
Normal file
98
apiserver/plane/payment/flags/provider.py
Normal file
@@ -0,0 +1,98 @@
|
||||
# Python imports
|
||||
import requests
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
|
||||
# Third party imports
|
||||
from openfeature.provider.provider import AbstractProvider
|
||||
from openfeature.provider.metadata import Metadata
|
||||
from openfeature.flag_evaluation import FlagResolutionDetails
|
||||
|
||||
# Module imports
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
class FlagProvider(AbstractProvider):
|
||||
def get_metadata(self) -> Metadata:
|
||||
return Metadata(name="PlaneProvider")
|
||||
|
||||
def make_request(self, slug, user_id, feature_key, default_value):
|
||||
# Make a request to the feature flag server to get the value of the feature flag
|
||||
if settings.FEATURE_FLAG_SERVER_BASE_URL:
|
||||
try:
|
||||
# Make a request to the feature flag server
|
||||
response = requests.post(
|
||||
f"{settings.FEATURE_FLAG_SERVER_BASE_URL}/api/feature-flags/",
|
||||
headers={
|
||||
"x-api-key": settings.FEATURE_FLAG_SERVER_AUTH_TOKEN,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"workspace_slug": slug,
|
||||
"user_id": user_id,
|
||||
"flag_key": feature_key,
|
||||
},
|
||||
)
|
||||
# If the request is successful, return the value of the feature flag
|
||||
response.raise_for_status()
|
||||
# Get the value of the feature flag from the response
|
||||
resp = response.json()
|
||||
return resp.get("value", default_value)
|
||||
# If the request fails, log the exception and return the default value
|
||||
except requests.exceptions.RequestException as e:
|
||||
log_exception(e)
|
||||
return default_value
|
||||
return default_value
|
||||
|
||||
def resolve_boolean_details(
|
||||
self,
|
||||
flag_key,
|
||||
default_value,
|
||||
evaluation_context,
|
||||
):
|
||||
# Get the targeting key and attributes from the evaluation context
|
||||
targetting_key = evaluation_context.targeting_key
|
||||
attributes = evaluation_context.attributes
|
||||
slug = attributes.get("slug")
|
||||
# Get the value of the feature flag
|
||||
value = self.make_request(
|
||||
user_id=targetting_key,
|
||||
slug=slug,
|
||||
feature_key=flag_key,
|
||||
default_value=default_value,
|
||||
)
|
||||
# Return the value of the feature flag
|
||||
return FlagResolutionDetails(value=value)
|
||||
|
||||
def resolve_string_details(
|
||||
self,
|
||||
flag_key,
|
||||
default_value,
|
||||
evaluation_context,
|
||||
):
|
||||
pass
|
||||
|
||||
def resolve_integer_details(
|
||||
self,
|
||||
flag_key,
|
||||
default_value,
|
||||
evaluation_context,
|
||||
):
|
||||
pass
|
||||
|
||||
def resolve_float_details(
|
||||
self,
|
||||
flag_key,
|
||||
default_value,
|
||||
evaluation_context,
|
||||
):
|
||||
pass
|
||||
|
||||
def resolve_object_details(
|
||||
self,
|
||||
flag_key,
|
||||
default_value,
|
||||
evaluation_context,
|
||||
):
|
||||
pass
|
||||
@@ -6,6 +6,7 @@ from .views import (
|
||||
WorkspaceProductEndpoint,
|
||||
WebsitePaymentLinkEndpoint,
|
||||
WebsiteUserWorkspaceEndpoint,
|
||||
SubscriptionEndpoint,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
@@ -34,4 +35,9 @@ urlpatterns = [
|
||||
WebsiteUserWorkspaceEndpoint.as_view(),
|
||||
name="website-workspaces",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/subscriptions/",
|
||||
SubscriptionEndpoint.as_view(),
|
||||
name="subscription",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -4,3 +4,4 @@ from .product import (
|
||||
WebsiteUserWorkspaceEndpoint,
|
||||
)
|
||||
from .payment import PaymentLinkEndpoint, WebsitePaymentLinkEndpoint
|
||||
from .subscription import SubscriptionEndpoint
|
||||
|
||||
@@ -30,7 +30,9 @@ class PaymentLinkEndpoint(BaseAPIView):
|
||||
workspace__slug=slug, is_active=True, member__is_bot=False
|
||||
)
|
||||
.annotate(
|
||||
user_email=F("member__email"), user_id=F("member__id")
|
||||
user_email=F("member__email"),
|
||||
user_id=F("member__id"),
|
||||
user_role=F("role"),
|
||||
)
|
||||
.values("user_email", "user_id")
|
||||
)
|
||||
@@ -114,7 +116,9 @@ class WebsitePaymentLinkEndpoint(BaseAPIView):
|
||||
workspace__slug=slug, is_active=True, member__is_bot=False
|
||||
)
|
||||
.annotate(
|
||||
user_email=F("member__email"), user_id=F("member__id")
|
||||
user_email=F("member__email"),
|
||||
user_id=F("member__id"),
|
||||
user_role=F("role"),
|
||||
)
|
||||
.values("user_email", "user_id")
|
||||
)
|
||||
|
||||
@@ -27,17 +27,40 @@ class ProductEndpoint(BaseAPIView):
|
||||
def get(self, request, slug):
|
||||
try:
|
||||
if settings.PAYMENT_SERVER_BASE_URL:
|
||||
count = WorkspaceMember.objects.filter(
|
||||
workspace__slug=slug
|
||||
# Get all the paid users in the workspace
|
||||
paid_count = WorkspaceMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
is_active=True,
|
||||
member__is_bot=False,
|
||||
role__gt=10,
|
||||
).count()
|
||||
|
||||
# Get all the viewers and guests in the workspace
|
||||
free_count = WorkspaceMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
is_active=True,
|
||||
member__is_bot=False,
|
||||
role__lte=10,
|
||||
).count()
|
||||
|
||||
# If paid users are currently the pay workspace count
|
||||
workspace_count = paid_count
|
||||
|
||||
# If free users are more than 5 times the paid users, then workspace count is free users - 5 * paid users
|
||||
if free_count > 5 * paid_count:
|
||||
workspace_count = free_count - 5 * paid_count
|
||||
|
||||
# Fetch the products from the payment server
|
||||
response = requests.get(
|
||||
f"{settings.PAYMENT_SERVER_BASE_URL}/api/products/?quantity={count}",
|
||||
f"{settings.PAYMENT_SERVER_BASE_URL}/api/products/?quantity={workspace_count}",
|
||||
headers={
|
||||
"content-type": "application/json",
|
||||
"x-api-key": settings.PAYMENT_SERVER_AUTH_TOKEN,
|
||||
},
|
||||
)
|
||||
# Check if the response is successful
|
||||
response.raise_for_status()
|
||||
# Convert the response to json
|
||||
response = response.json()
|
||||
return Response(response, status=status.HTTP_200_OK)
|
||||
else:
|
||||
@@ -96,7 +119,12 @@ class WebsiteUserWorkspaceEndpoint(BaseAPIView):
|
||||
role=20,
|
||||
)
|
||||
.annotate(uuid_str=Cast("workspace_id", CharField()))
|
||||
.values("uuid_str", "workspace__slug", "workspace__name", "workspace__logo")
|
||||
.values(
|
||||
"uuid_str",
|
||||
"workspace__slug",
|
||||
"workspace__name",
|
||||
"workspace__logo",
|
||||
)
|
||||
)
|
||||
|
||||
workspaces = [
|
||||
|
||||
47
apiserver/plane/payment/views/subscription.py
Normal file
47
apiserver/plane/payment/views/subscription.py
Normal file
@@ -0,0 +1,47 @@
|
||||
# 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 WorkspaceOwnerPermission
|
||||
from plane.db.models import Workspace
|
||||
|
||||
|
||||
class SubscriptionEndpoint(BaseAPIView):
|
||||
|
||||
permission_classes = [
|
||||
WorkspaceOwnerPermission,
|
||||
]
|
||||
|
||||
def post(self, request, slug):
|
||||
# Get the workspace
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
|
||||
# Fetch the workspace subcription
|
||||
if settings.PAYMENT_SERVER_BASE_URL:
|
||||
# Make a cancel request to the payment server
|
||||
response = requests.post(
|
||||
f"{settings.PAYMENT_SERVER_BASE_URL}/api/subscriptions/check/",
|
||||
headers={
|
||||
"content-type": "application/json",
|
||||
"x-api-key": settings.PAYMENT_SERVER_AUTH_TOKEN,
|
||||
},
|
||||
json={"workspace_id": str(workspace.id)},
|
||||
)
|
||||
# Check if the response is successful
|
||||
response.raise_for_status()
|
||||
# Return the response
|
||||
response = response.json()
|
||||
# Check if the response contains the product key
|
||||
return Response(response, status=status.HTTP_200_OK)
|
||||
return Response(
|
||||
{"error": "error in checking workspace subscription"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
@@ -366,6 +366,14 @@ 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
|
||||
PAYMENT_SERVER_BASE_URL = os.environ.get("PAYMENT_SERVER_BASE_URL", False)
|
||||
PAYMENT_SERVER_AUTH_TOKEN = os.environ.get("PAYMENT_SERVER_AUTH_TOKEN", "")
|
||||
|
||||
# feature flag server base urls
|
||||
FEATURE_FLAG_SERVER_BASE_URL = os.environ.get(
|
||||
"FEATURE_FLAG_SERVER_BASE_URL", False
|
||||
)
|
||||
FEATURE_FLAG_SERVER_AUTH_TOKEN = os.environ.get(
|
||||
"FEATURE_FLAG_SERVER_AUTH_TOKEN", ""
|
||||
)
|
||||
|
||||
@@ -65,5 +65,7 @@ pytz==2024.1
|
||||
PyJWT==2.8.0
|
||||
# SAML
|
||||
python3-saml==1.16.0
|
||||
# feature flag
|
||||
openfeature-sdk==0.7.0
|
||||
# graphql
|
||||
strawberry-graphql-django==0.43.0
|
||||
|
||||
@@ -39,13 +39,19 @@ export const DocumentEditorAdditionalExtensions = (props: Props) => {
|
||||
},
|
||||
];
|
||||
|
||||
const extensions = [SlashCommand(fileHandler.upload, slashCommandAdditionalOptions)];
|
||||
let extensions = [];
|
||||
// If searchCallback is provided, then add the slash command for issue embed. This check is required as the searchCallback is optional.
|
||||
if (issueEmbedConfig?.searchCallback) {
|
||||
extensions = [SlashCommand(fileHandler.upload, slashCommandAdditionalOptions)];
|
||||
} else {
|
||||
extensions = [SlashCommand(fileHandler.upload)];
|
||||
}
|
||||
|
||||
if (issueEmbedConfig)
|
||||
if (issueEmbedConfig && issueEmbedConfig.searchCallback)
|
||||
extensions.push(
|
||||
IssueEmbedSuggestions.configure({
|
||||
suggestion: {
|
||||
render: () => IssueListRenderer(issueEmbedConfig?.searchCallback),
|
||||
render: () => issueEmbedConfig.searchCallback && IssueListRenderer(issueEmbedConfig.searchCallback),
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
@@ -10,7 +10,7 @@ export type TReadOnlyEmbedConfig = {
|
||||
};
|
||||
|
||||
export type TIssueEmbedConfig = {
|
||||
searchCallback: (searchQuery: string) => Promise<TEmbedItem[]>;
|
||||
searchCallback?: (searchQuery: string) => Promise<TEmbedItem[]>;
|
||||
widgetCallback: ({
|
||||
issueId,
|
||||
projectId,
|
||||
|
||||
2
packages/types/src/payment.d.ts
vendored
2
packages/types/src/payment.d.ts
vendored
@@ -19,5 +19,5 @@ export type IPaymentProduct = {
|
||||
|
||||
export type IWorkspaceProductSubscription = {
|
||||
product: TProductSubscriptionType;
|
||||
expiry_date: string | null;
|
||||
current_period_end_date: string | null;
|
||||
};
|
||||
|
||||
@@ -27,7 +27,8 @@
|
||||
"NEXT_PUBLIC_PRO_PLAN_MONTHLY_REDIRECT_URL",
|
||||
"NEXT_PUBLIC_PRO_PLAN_YEARLY_REDIRECT_URL",
|
||||
"NEXT_PUBLIC_DISCO_BASE_URL",
|
||||
"NEXT_PUBLIC_PRO_SELF_HOSTED_PAYMENT_URL"
|
||||
"NEXT_PUBLIC_PRO_SELF_HOSTED_PAYMENT_URL",
|
||||
"NEXT_PUBLIC_FEATURE_FLAG_SERVER_BASE_URL"
|
||||
],
|
||||
"tasks": {
|
||||
"build": {
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { CustomError } from "@/components/common";
|
||||
|
||||
export default function ActiveCyclesError() {
|
||||
return <CustomError />;
|
||||
}
|
||||
43
web/core/components/common/custom-error.tsx
Normal file
43
web/core/components/common/custom-error.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@plane/ui";
|
||||
|
||||
export const CustomError = () => {
|
||||
const handleRefresh = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`h-full w-full overflow-hidden bg-custom-background-100`}>
|
||||
<div className="grid h-full place-items-center p-4">
|
||||
<div className="space-y-8 text-center">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold">Yikes! That doesn{"'"}t look good.</h3>
|
||||
<p className="mx-auto md:w-1/2 text-sm text-custom-text-200">
|
||||
That crashed Plane, pun intended. No worries, though. Our engineers have been notified. If you have more
|
||||
details, please write to{" "}
|
||||
<a href="mailto:support@plane.so" className="text-custom-primary">
|
||||
support@plane.so
|
||||
</a>{" "}
|
||||
or on our{" "}
|
||||
<a
|
||||
href="https://discord.com/invite/A92xrEGCge"
|
||||
target="_blank"
|
||||
className="text-custom-primary"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Discord
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Button variant="primary" size="md" onClick={handleRefresh}>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -4,4 +4,5 @@ export * from "./latest-feature-block";
|
||||
export * from "./breadcrumb-link";
|
||||
export * from "./logo-spinner";
|
||||
export * from "./logo";
|
||||
export * from "./custom-error";
|
||||
export * from "./count-chip";
|
||||
|
||||
@@ -13,6 +13,8 @@ import { Button, TOAST_TYPE, setToast, Tooltip } from "@plane/ui";
|
||||
import { LogoSpinner } from "@/components/common";
|
||||
import { useMember, useProject, useUser, useWorkspace } from "@/hooks/store";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// plane web hooks
|
||||
import { useFeatureFlags } from "@/plane-web/hooks/store/use-feature-flags";
|
||||
// images
|
||||
import PlaneBlackLogo from "@/public/plane-logos/black-horizontal-with-blue-logo.png";
|
||||
import PlaneWhiteLogo from "@/public/plane-logos/white-horizontal-with-blue-logo.png";
|
||||
@@ -35,6 +37,7 @@ export const WorkspaceAuthWrapper: FC<IWorkspaceAuthWrapper> = observer((props)
|
||||
workspace: { fetchWorkspaceMembers },
|
||||
} = useMember();
|
||||
const { workspaces } = useWorkspace();
|
||||
const { loader: featureFlagsLoader, fetchFeatureFlags } = useFeatureFlags();
|
||||
const { isMobile } = usePlatformOS();
|
||||
|
||||
const planeLogo = resolvedTheme === "dark" ? PlaneWhiteLogo : PlaneBlackLogo;
|
||||
@@ -42,6 +45,13 @@ export const WorkspaceAuthWrapper: FC<IWorkspaceAuthWrapper> = observer((props)
|
||||
const currentWorkspace =
|
||||
(allWorkspaces && allWorkspaces.find((workspace) => workspace?.slug === workspaceSlug)) || undefined;
|
||||
|
||||
// fetching feature flags
|
||||
useSWR(
|
||||
workspaceSlug && currentUser ? `WORKSPACE_FEATURE_FLAGS_${workspaceSlug}_${currentUser.id}` : null,
|
||||
workspaceSlug && currentUser ? () => fetchFeatureFlags(workspaceSlug.toString(), currentUser.id) : null,
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
// fetching user workspace information
|
||||
useSWR(
|
||||
workspaceSlug && currentWorkspace ? `WORKSPACE_MEMBERS_ME_${workspaceSlug}` : null,
|
||||
@@ -80,7 +90,7 @@ export const WorkspaceAuthWrapper: FC<IWorkspaceAuthWrapper> = observer((props)
|
||||
};
|
||||
|
||||
// if list of workspaces are not there then we have to render the spinner
|
||||
if (allWorkspaces === undefined) {
|
||||
if (featureFlagsLoader || allWorkspaces === undefined) {
|
||||
return (
|
||||
<div className="grid h-screen place-items-center bg-custom-background-100 p-4">
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
// plane web components
|
||||
import { WorkspaceActiveCyclesList } from "@/plane-web/components/active-cycles";
|
||||
import { WorkspaceActiveCyclesList, WorkspaceActiveCyclesUpgrade } from "@/plane-web/components/active-cycles";
|
||||
import { WithFeatureFlagHOC } from "@/plane-web/components/feature-flags";
|
||||
|
||||
export const WorkspaceActiveCyclesRoot = () => <WorkspaceActiveCyclesList />;
|
||||
export const WorkspaceActiveCyclesRoot = () => (
|
||||
<WithFeatureFlagHOC flag="WORKSPACE_ACTIVE_CYCLES" fallback={<WorkspaceActiveCyclesUpgrade />}>
|
||||
<WorkspaceActiveCyclesList />
|
||||
</WithFeatureFlagHOC>
|
||||
)
|
||||
|
||||
@@ -1 +1,90 @@
|
||||
export * from "ce/components/active-cycles/workspace-active-cycles-upgrade";
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Image from "next/image";
|
||||
// icons
|
||||
import { Crown } from "lucide-react";
|
||||
// ui
|
||||
import { Button } from "@plane/ui";
|
||||
// constants
|
||||
import { WORKSPACE_ACTIVE_CYCLES_DETAILS } from "@/constants/cycle";
|
||||
// helper
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// hooks
|
||||
import { useUser } from "@/hooks/store";
|
||||
import { useWorkspaceSubscription } from "@/plane-web/hooks/store";
|
||||
|
||||
export const WorkspaceActiveCyclesUpgrade = observer(() => {
|
||||
// store hooks
|
||||
const {
|
||||
userProfile: { data: userProfile },
|
||||
} = useUser();
|
||||
const { toggleProPlanModal } = useWorkspaceSubscription();
|
||||
|
||||
const isDarkMode = userProfile?.theme.theme === "dark";
|
||||
|
||||
return (
|
||||
<div className="vertical-scrollbar scrollbar-lg flex h-full flex-col gap-10 rounded-xl px-8 pt-8">
|
||||
<div
|
||||
className={cn("item-center flex min-h-[25rem] justify-between rounded-xl", {
|
||||
"bg-gradient-to-l from-[#CFCFCF] to-[#212121]": userProfile?.theme.theme === "dark",
|
||||
"bg-gradient-to-l from-[#3b5ec6] to-[#f5f7fe]": userProfile?.theme.theme === "light",
|
||||
})}
|
||||
>
|
||||
<div className="relative flex flex-col justify-center gap-7 px-14 lg:w-1/2">
|
||||
<div className="flex max-w-64 flex-col gap-2">
|
||||
<h2 className="text-2xl font-semibold">On-demand snapshots of all your cycles</h2>
|
||||
<p className="text-base font-medium text-custom-text-300">
|
||||
Monitor cycles across projects, track high-priority issues, and zoom in cycles that need attention.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="primary" onClick={() => toggleProPlanModal(true)}>
|
||||
<Crown className="h-3.5 w-3.5" />
|
||||
Upgrade
|
||||
</Button>
|
||||
</div>
|
||||
<span className="absolute left-0 top-0">
|
||||
<Image
|
||||
src={`/workspace-active-cycles/cta-l-1-${isDarkMode ? "dark" : "light"}.webp`}
|
||||
height={125}
|
||||
width={125}
|
||||
className="rounded-xl"
|
||||
alt="l-1"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative hidden w-1/2 lg:block">
|
||||
<span className="absolute bottom-0 right-0">
|
||||
<Image
|
||||
src={`/workspace-active-cycles/cta-r-1-${isDarkMode ? "dark" : "light"}.webp`}
|
||||
height={420}
|
||||
width={500}
|
||||
alt="r-1"
|
||||
/>
|
||||
</span>
|
||||
<span className="absolute -bottom-16 right-1/2 rounded-xl">
|
||||
<Image
|
||||
src={`/workspace-active-cycles/cta-r-2-${isDarkMode ? "dark" : "light"}.webp`}
|
||||
height={210}
|
||||
width={280}
|
||||
alt="r-2"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid h-full grid-cols-1 gap-5 pb-8 lg:grid-cols-2 xl:grid-cols-3">
|
||||
{WORKSPACE_ACTIVE_CYCLES_DETAILS.map((item) => (
|
||||
<div key={item.title} className="flex min-h-32 w-full flex-col gap-2 rounded-md bg-custom-background-80 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium">{item.title}</h3>
|
||||
<item.icon className="h-4 w-4 text-blue-500" />
|
||||
</div>
|
||||
<span className="text-sm text-custom-text-300">{item.description}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Spinner } from "@plane/ui";
|
||||
// components
|
||||
import { WORKSPACE_ACTIVE_CYCLES_LIST } from "@/constants/fetch-keys";
|
||||
// plane web components
|
||||
import { WorkspaceActiveCyclesUpgrade } from "@/plane-web/components/active-cycles/";
|
||||
import { ActiveCycleInfoCard } from "@/plane-web/components/cycles/active-cycles";
|
||||
// constants
|
||||
// services
|
||||
@@ -24,7 +25,7 @@ export const ActiveCyclesListPage: FC<ActiveCyclesListPageProps> = (props) => {
|
||||
const { workspaceSlug, cursor, perPage, updateTotalPages, updateResultsCount } = props;
|
||||
|
||||
// fetching active cycles in workspace
|
||||
const { data: workspaceActiveCycles } = useSWR(
|
||||
const { data: workspaceActiveCycles, error } = useSWR(
|
||||
workspaceSlug && cursor ? WORKSPACE_ACTIVE_CYCLES_LIST(workspaceSlug as string, cursor, `${perPage}`) : null,
|
||||
workspaceSlug && cursor ? () => cycleService.workspaceActiveCycles(workspaceSlug.toString(), cursor, perPage) : null
|
||||
);
|
||||
@@ -36,6 +37,11 @@ export const ActiveCyclesListPage: FC<ActiveCyclesListPageProps> = (props) => {
|
||||
}
|
||||
}, [updateTotalPages, updateResultsCount, workspaceActiveCycles]);
|
||||
|
||||
if (error) {
|
||||
if (error.error_code === 1999) return <WorkspaceActiveCyclesUpgrade />;
|
||||
else throw Error(error.error);
|
||||
}
|
||||
|
||||
if (!workspaceActiveCycles) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full w-full">
|
||||
|
||||
1
web/ee/components/feature-flags/index.tsx
Normal file
1
web/ee/components/feature-flags/index.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export * from './with-feature-flag-hoc';
|
||||
15
web/ee/components/feature-flags/with-feature-flag-hoc.tsx
Normal file
15
web/ee/components/feature-flags/with-feature-flag-hoc.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { ReactNode } from "react";
|
||||
// plane web hooks
|
||||
import { E_FEATURE_FLAGS, useFlag } from "@/plane-web/hooks/store/use-flag";
|
||||
|
||||
interface IWithFeatureFlagHOC {
|
||||
flag: keyof typeof E_FEATURE_FLAGS;
|
||||
fallback: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const WithFeatureFlagHOC = ({ flag, fallback, children }: IWithFeatureFlagHOC) => {
|
||||
const isFeatureEnabled = useFlag(flag);
|
||||
|
||||
return <>{isFeatureEnabled ? children : fallback}</>;
|
||||
};
|
||||
@@ -17,11 +17,11 @@ export const CloudEditionBadge = observer(() => {
|
||||
// params
|
||||
const { workspaceSlug } = useParams();
|
||||
// states
|
||||
const [isProPlanModalOpen, setIsProPlanModalOpen] = useState(false);
|
||||
const [isProPlanDetailsModalOpen, setProPlanDetailsModalOpen] = useState(false);
|
||||
// hooks
|
||||
const { captureEvent } = useEventTracker();
|
||||
const { currentWorkspaceSubscribedPlan, fetchWorkspaceSubscribedPlan } = useWorkspaceSubscription();
|
||||
const { isProPlanModalOpen, currentWorkspaceSubscribedPlanDetail, toggleProPlanModal, fetchWorkspaceSubscribedPlan } =
|
||||
useWorkspaceSubscription();
|
||||
// fetch workspace current plane information
|
||||
useSWR(
|
||||
workspaceSlug && process.env.NEXT_PUBLIC_DISCO_BASE_URL ? `WORKSPACE_CURRENT_PLAN_${workspaceSlug}` : null,
|
||||
@@ -36,7 +36,7 @@ export const CloudEditionBadge = observer(() => {
|
||||
);
|
||||
|
||||
const handleProPlanPurchaseModalOpen = () => {
|
||||
setIsProPlanModalOpen(true);
|
||||
toggleProPlanModal(true);
|
||||
captureEvent("pro_plan_modal_opened", {});
|
||||
};
|
||||
|
||||
@@ -45,7 +45,7 @@ export const CloudEditionBadge = observer(() => {
|
||||
captureEvent("pro_plan_details_modal_opened", {});
|
||||
};
|
||||
|
||||
if (!currentWorkspaceSubscribedPlan)
|
||||
if (!currentWorkspaceSubscribedPlanDetail)
|
||||
return (
|
||||
<Loader className="flex h-full">
|
||||
<Loader.Item height="30px" width="95%" />
|
||||
@@ -54,28 +54,35 @@ export const CloudEditionBadge = observer(() => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<CloudProductsModal isOpen={isProPlanModalOpen} handleClose={() => setIsProPlanModalOpen(false)} />
|
||||
<ProPlanDetailsModal isOpen={isProPlanDetailsModalOpen} handleClose={() => setProPlanDetailsModalOpen(false)} />
|
||||
{currentWorkspaceSubscribedPlan === "FREE" && (
|
||||
<Button
|
||||
tabIndex={-1}
|
||||
variant="accent-primary"
|
||||
className="w-full cursor-pointer rounded-2xl px-4 py-1.5 text-center text-sm font-medium outline-none"
|
||||
onClick={handleProPlanPurchaseModalOpen}
|
||||
>
|
||||
Upgrade to Pro
|
||||
</Button>
|
||||
{currentWorkspaceSubscribedPlanDetail.product === "FREE" && (
|
||||
<>
|
||||
<CloudProductsModal isOpen={isProPlanModalOpen} handleClose={() => toggleProPlanModal(false)} />
|
||||
<Button
|
||||
tabIndex={-1}
|
||||
variant="accent-primary"
|
||||
className="w-full cursor-pointer rounded-2xl px-4 py-1.5 text-center text-sm font-medium outline-none"
|
||||
onClick={handleProPlanPurchaseModalOpen}
|
||||
>
|
||||
Upgrade to Pro
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{currentWorkspaceSubscribedPlan === "PRO" && (
|
||||
<Button
|
||||
tabIndex={-1}
|
||||
variant="accent-primary"
|
||||
className="w-full cursor-pointer rounded-2xl px-3 py-1.5 text-center text-sm font-medium outline-none"
|
||||
onClick={handleProPlanDetailsModalOpen}
|
||||
>
|
||||
<Image src={PlaneLogo} alt="Plane Pro" width={14} height={14} />
|
||||
{"Plane Pro"}
|
||||
</Button>
|
||||
{currentWorkspaceSubscribedPlanDetail.product === "PRO" && (
|
||||
<>
|
||||
<ProPlanDetailsModal
|
||||
isOpen={isProPlanDetailsModalOpen}
|
||||
handleClose={() => setProPlanDetailsModalOpen(false)}
|
||||
/>
|
||||
<Button
|
||||
tabIndex={-1}
|
||||
variant="accent-primary"
|
||||
className="w-full cursor-pointer rounded-2xl px-3 py-1.5 text-center text-sm font-medium outline-none"
|
||||
onClick={handleProPlanDetailsModalOpen}
|
||||
>
|
||||
<Image src={PlaneLogo} alt="Plane Pro" width={14} height={14} />
|
||||
{"Plane Pro"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,23 +1,122 @@
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Image from "next/image";
|
||||
import { useParams } from "next/navigation";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
// ui
|
||||
import { Button } from "@plane/ui";
|
||||
// constants
|
||||
import { MARKETING_PRICING_PAGE_LINK } from "@/constants/common";
|
||||
import { Button, Loader, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui";
|
||||
// store hooks
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { renderFormattedDate } from "@/helpers/date-time.helper";
|
||||
import { useWorkspaceSubscription } from "@/plane-web/hooks/store";
|
||||
// services
|
||||
import { PaymentService } from "@/plane-web/services/payment.service";
|
||||
// assets
|
||||
import PlaneLogo from "@/public/plane-logos/blue-without-text.png";
|
||||
|
||||
export const PlaneCloudBilling: React.FC = () => (
|
||||
<section className="w-full overflow-y-auto md:pr-9 pr-4">
|
||||
<div>
|
||||
<div className="flex items-center border-b border-custom-border-100 py-3.5">
|
||||
<h3 className="text-xl font-medium">Billing & Plans</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-4 py-6">
|
||||
const paymentService = new PaymentService();
|
||||
|
||||
export const PlaneCloudBilling: React.FC = observer(() => {
|
||||
// params
|
||||
const { workspaceSlug } = useParams();
|
||||
// states
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
// hooks
|
||||
const { currentWorkspaceSubscribedPlanDetail, toggleProPlanModal } = useWorkspaceSubscription();
|
||||
|
||||
const handleSubscriptionPageRedirection = () => {
|
||||
setIsLoading(true);
|
||||
paymentService
|
||||
.getWorkspaceSubscriptionPageLink(workspaceSlug.toString())
|
||||
.then((response) => {
|
||||
if (response.url) {
|
||||
window.open(response.url, "_blank");
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Failed to redirect to subscription page. Please try again.",
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="w-full overflow-y-auto md:pr-9 pr-4">
|
||||
<div>
|
||||
<h4 className="text-md mb-1 leading-6">Current plan</h4>
|
||||
<p className="mb-3 text-sm text-custom-text-200">You are currently using the free plan</p>
|
||||
<a href={MARKETING_PRICING_PAGE_LINK} target="_blank" rel="noreferrer">
|
||||
<Button variant="neutral-primary">View Plans</Button>
|
||||
</a>
|
||||
<div className="flex items-center border-b border-custom-border-100 py-3.5">
|
||||
<h3 className="text-xl font-medium flex gap-4">
|
||||
Billing and plans{" "}
|
||||
<a
|
||||
href="https://plane.so/pricing"
|
||||
className={cn(
|
||||
getButtonStyling("neutral-primary", "sm"),
|
||||
"cursor-pointer rounded-2xl px-3 py-1 text-center text-xs font-medium outline-none"
|
||||
)}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
{"View all plans"}
|
||||
<ExternalLink className="h-3 w-3" strokeWidth={2} />
|
||||
</a>
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
<div className="py-6">
|
||||
{!currentWorkspaceSubscribedPlanDetail && (
|
||||
<Loader className="flex w-full justify-between">
|
||||
<Loader.Item height="30px" width="40%" />
|
||||
<Loader.Item height="30px" width="20%" />
|
||||
</Loader>
|
||||
)}
|
||||
{currentWorkspaceSubscribedPlanDetail?.product === "FREE" && (
|
||||
<div>
|
||||
<div className="flex gap-2 font-medium justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Image src={PlaneLogo} alt="Plane" width={24} height={24} />
|
||||
<h4 className="text-xl mb-1 leading-6 font-bold">Free plan</h4>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
tabIndex={-1}
|
||||
variant="accent-primary"
|
||||
className="w-full cursor-pointer rounded-2xl px-4 py-1.5 text-center text-sm font-medium outline-none"
|
||||
onClick={() => toggleProPlanModal(true)}
|
||||
>
|
||||
Upgrade to Pro
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{currentWorkspaceSubscribedPlanDetail?.product === "PRO" && (
|
||||
<div>
|
||||
<div className="flex flex-col sm:flex-row gap-4 text-lg font-medium justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Image src={PlaneLogo} alt="Plane pro" width={24} height={24} />
|
||||
<h4 className="text-2xl mb-1 leading-6 font-bold">Plane Pro</h4>
|
||||
<div className="text-center text-sm text-custom-text-200 font-medium">
|
||||
(Renew on: {renderFormattedDate(currentWorkspaceSubscribedPlanDetail.current_period_end_date)})
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from "ce/components/pages/editor/embed/issue-embed-upgrade-card";
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Crown } from "lucide-react";
|
||||
// ui
|
||||
import { Button } from "@plane/ui";
|
||||
import { useWorkspaceSubscription } from "@/plane-web/hooks/store";
|
||||
|
||||
export const IssueEmbedUpgradeCard: React.FC<any> = (props) => {
|
||||
const { toggleProPlanModal } = useWorkspaceSubscription();
|
||||
return (
|
||||
<div
|
||||
className={`${props.selected ? "border-custom-primary-200 border-[2px]" : ""
|
||||
} w-full h-[100px] cursor-pointer space-y-2 rounded-md border-[0.5px] border-custom-border-200 shadow-custom-shadow-2xs`}
|
||||
>
|
||||
<h5 className="h-[20%] text-xs text-custom-text-300 p-2">
|
||||
{props.node?.attrs?.project_identifier}-{props?.node?.attrs?.sequence_id}
|
||||
</h5>
|
||||
<div className="relative h-[71%]">
|
||||
<div className="h-full backdrop-filter backdrop-blur-[30px] bg-custom-background-80 bg-opacity-30 flex items-center w-full justify-between gap-5 mt-2.5 pl-4 pr-5 py-3 max-md:max-w-full max-md:flex-wrap relative">
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="rounded">
|
||||
<Crown className="m-2" size={16} color="#FFBA18" />
|
||||
</div>
|
||||
<div className="text-custom-text text-sm">
|
||||
Embed and access issues in pages seamlessly, upgrade to plane pro now.
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="primary" onClick={() => toggleProPlanModal(true)}>
|
||||
Upgrade
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -11,6 +11,7 @@ import { usePage } from "@/hooks/store";
|
||||
import { PublishPageModal } from "@/plane-web/components/pages";
|
||||
// plane web hooks
|
||||
import { usePublishPage } from "@/plane-web/hooks/store";
|
||||
import { useFlag } from "@/plane-web/hooks/store/use-flag";
|
||||
|
||||
export const PageDetailsHeaderExtraActions = observer(() => {
|
||||
// states
|
||||
@@ -21,12 +22,15 @@ export const PageDetailsHeaderExtraActions = observer(() => {
|
||||
const { anchor, isCurrentUserOwner } = usePage(pageId.toString());
|
||||
const { fetchProjectPagePublishSettings, getPagePublishSettings, publishProjectPage, unpublishProjectPage } =
|
||||
usePublishPage();
|
||||
const isPagePublishEnabled = useFlag("PAGE_PUBLISH");
|
||||
// derived values
|
||||
const isDeployed = !!anchor;
|
||||
const pagePublishSettings = getPagePublishSettings(pageId.toString());
|
||||
|
||||
const publishLink = `${SPACE_BASE_URL}/pages/${anchor}`;
|
||||
|
||||
if (!isPagePublishEnabled) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PublishPageModal
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
export * from "./root";
|
||||
export * from "./root";
|
||||
export * from "./plane-cloud-plans";
|
||||
export * from "./plan-card";
|
||||
|
||||
93
web/ee/components/workspace/billing/plan-card.tsx
Normal file
93
web/ee/components/workspace/billing/plan-card.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { FC } from "react";
|
||||
import { BadgeCheck } from "lucide-react";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
|
||||
type TPlaneCardVariant = "free" | "one" | "pro" | "enterprise";
|
||||
|
||||
type TButtonCallToAction = {
|
||||
variant: "button";
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
type TLinkCallToAction = {
|
||||
variant: "link";
|
||||
label: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type TPlanCard = {
|
||||
variant: TPlaneCardVariant;
|
||||
planName: string;
|
||||
isActive: boolean;
|
||||
priceDetails: {
|
||||
price: string;
|
||||
user?: string;
|
||||
duration?: string;
|
||||
};
|
||||
callToAction: TButtonCallToAction | TLinkCallToAction;
|
||||
baseFeature?: string;
|
||||
features: string[];
|
||||
};
|
||||
|
||||
export const PlanCard: FC<TPlanCard> = (props) => {
|
||||
const { variant, planName, isActive, priceDetails, callToAction, baseFeature, features } = props;
|
||||
|
||||
return (
|
||||
<div className="py-2 px-4">
|
||||
<div className="p-4 border border-custom-border-200 rounded-xl">
|
||||
<div className="flex gap-4 items-center justify-between">
|
||||
<div className="text-xl font-bold">{planName}</div>
|
||||
{isActive && (
|
||||
<div className="border-[0.5px] border-custom-primary-100 p-0.5 bg-custom-primary-100/10 text-custom-primary-300 text-xs font-medium rounded">
|
||||
Current plan
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-4 items-center my-4">
|
||||
<div className="text-2xl font-semibold">{priceDetails.price}</div>
|
||||
<div className="flex flex-col w-full text-xs text-custom-text-300 font-medium">
|
||||
<span className="line-clamp-1">{priceDetails.user}</span>
|
||||
<span className="line-clamp-2">{priceDetails.duration}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{callToAction.variant === "button" ? (
|
||||
<button
|
||||
onClick={callToAction.onClick}
|
||||
className={cn(
|
||||
"w-full text-center px-4 py-2 text-base font-semibold text-custom-primary-100 bg-custom-background-100 border border-custom-primary-100 rounded-lg"
|
||||
)}
|
||||
>
|
||||
{callToAction.label}
|
||||
</button>
|
||||
) : (
|
||||
<a
|
||||
href={callToAction.url}
|
||||
target="_blank"
|
||||
className={cn(
|
||||
"block w-full text-center px-4 py-2 text-base font-semibold text-custom-primary-100 bg-custom-background-100 border border-custom-primary-100 rounded-lg"
|
||||
)}
|
||||
>
|
||||
{callToAction.label}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-4">
|
||||
<div className="text-sm font-semibold pt-2 h-8">{baseFeature}</div>
|
||||
<ul className="text-sm font-medium">
|
||||
{features.map((feature) => (
|
||||
<li key={feature} className="flex gap-2 items-start py-3 w-full h-12">
|
||||
<span className="flex-shrink-0 pt-0.5">
|
||||
<BadgeCheck size={14} />
|
||||
</span>
|
||||
<span className="line-clamp-2">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
144
web/ee/components/workspace/billing/plane-cloud-plans.tsx
Normal file
144
web/ee/components/workspace/billing/plane-cloud-plans.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// hooks
|
||||
import { useAppTheme } from "@/hooks/store";
|
||||
// plane web components
|
||||
import { PlanCard, TPlanCard } from "@/plane-web/components/workspace/billing";
|
||||
// plane web hooks
|
||||
import { useWorkspaceSubscription } from "@/plane-web/hooks/store";
|
||||
|
||||
const freePlanFeatures = [
|
||||
"Unlimited projects",
|
||||
"Unlimited issues",
|
||||
"Cycles and Modules",
|
||||
"Layouts + Views",
|
||||
"Pages",
|
||||
"Intake",
|
||||
];
|
||||
|
||||
const onePlanFeatures = [
|
||||
"OIDC and SAML",
|
||||
"Active Cycles",
|
||||
"Real-time collab",
|
||||
"Limited time tracking",
|
||||
"Linked pages",
|
||||
"Docker, Kubernetes + more",
|
||||
];
|
||||
|
||||
const proPlanFeatures = [
|
||||
"Active Cycles + other Cycles features",
|
||||
"Bulk ops",
|
||||
"Time tracking",
|
||||
"Customizable dashboards",
|
||||
"On-demand reports",
|
||||
"Shared and public views",
|
||||
];
|
||||
|
||||
const enterprisePlanFeatures = [
|
||||
"Unlimited Issues",
|
||||
"Unlimited file upload",
|
||||
"Priority support",
|
||||
"Custom Theming",
|
||||
"Access to Roadmap",
|
||||
"Plane AI",
|
||||
];
|
||||
|
||||
export const PlaneCloudPlans: FC = observer(() => {
|
||||
// hooks
|
||||
const { sidebarCollapsed } = useAppTheme();
|
||||
const { toggleProPlanModal } = useWorkspaceSubscription();
|
||||
|
||||
const planePlans: TPlanCard[] = [
|
||||
{
|
||||
variant: "free",
|
||||
planName: "Free",
|
||||
isActive: true,
|
||||
priceDetails: {
|
||||
price: "$0",
|
||||
user: "per user",
|
||||
duration: "per month",
|
||||
},
|
||||
callToAction: {
|
||||
variant: "link",
|
||||
label: "Start for free",
|
||||
url: "https://plane.so/pricing",
|
||||
},
|
||||
features: freePlanFeatures,
|
||||
},
|
||||
{
|
||||
variant: "one",
|
||||
planName: "One",
|
||||
isActive: false,
|
||||
priceDetails: {
|
||||
price: "$799",
|
||||
user: "100 users",
|
||||
duration: "Two years' support",
|
||||
},
|
||||
callToAction: {
|
||||
variant: "link",
|
||||
label: "Get One",
|
||||
url: "https://plane.so/one",
|
||||
},
|
||||
baseFeature: "Everything in Free +",
|
||||
features: onePlanFeatures,
|
||||
},
|
||||
{
|
||||
variant: "pro",
|
||||
planName: "Pro",
|
||||
isActive: false,
|
||||
priceDetails: {
|
||||
price: "$7",
|
||||
user: "per user",
|
||||
duration: "per month",
|
||||
},
|
||||
callToAction: {
|
||||
variant: "button",
|
||||
label: "Get Pro",
|
||||
onClick: () => toggleProPlanModal(true),
|
||||
},
|
||||
baseFeature: "Everything in One +",
|
||||
features: proPlanFeatures,
|
||||
},
|
||||
{
|
||||
variant: "enterprise",
|
||||
planName: "Enterprise",
|
||||
isActive: false,
|
||||
priceDetails: {
|
||||
price: "Custom",
|
||||
},
|
||||
callToAction: {
|
||||
variant: "link",
|
||||
label: "Talk to Sales",
|
||||
url: "https://plane.so/contact",
|
||||
},
|
||||
baseFeature: "Everything in Pro +",
|
||||
features: enterprisePlanFeatures,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-12 w-full py-8">
|
||||
{planePlans.map((plan) => (
|
||||
<div
|
||||
key={plan.variant}
|
||||
className={cn("col-span-12 sm:col-span-6 md:col-span-12 lg:col-span-6", {
|
||||
"lg:col-span-6 xl:col-span-3": sidebarCollapsed,
|
||||
"lg:col-span-12 xl:col-span-3": !sidebarCollapsed,
|
||||
})}
|
||||
>
|
||||
<PlanCard
|
||||
variant={plan.variant}
|
||||
planName={plan.planName}
|
||||
isActive={plan.isActive}
|
||||
priceDetails={plan.priceDetails}
|
||||
callToAction={plan.callToAction}
|
||||
baseFeature={plan.baseFeature}
|
||||
features={plan.features}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1 +1,38 @@
|
||||
export * from "ce/components/workspace/upgrade-badge";
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// store
|
||||
import { useWorkspaceSubscription } from "@/plane-web/hooks/store";
|
||||
|
||||
type TUpgradeBadge = {
|
||||
className?: string;
|
||||
size?: "sm" | "md";
|
||||
};
|
||||
|
||||
export const UpgradeBadge: FC<TUpgradeBadge> = observer((props) => {
|
||||
const { className, size = "sm" } = props;
|
||||
// store hooks
|
||||
const { currentWorkspaceSubscribedPlanDetail } = useWorkspaceSubscription();
|
||||
// derived values
|
||||
const isSubscribedToPro = currentWorkspaceSubscribedPlanDetail?.product === "PRO";
|
||||
|
||||
if (!currentWorkspaceSubscribedPlanDetail || isSubscribedToPro) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-fit cursor-pointer rounded-2xl text-custom-primary-200 bg-custom-primary-100/20 text-center font-medium outline-none",
|
||||
{
|
||||
"text-sm px-3": size === "md",
|
||||
"text-xs px-2": size === "sm",
|
||||
},
|
||||
className
|
||||
)}
|
||||
>
|
||||
Pro
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
11
web/ee/hooks/store/use-feature-flags.ts
Normal file
11
web/ee/hooks/store/use-feature-flags.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { useContext } from "react";
|
||||
// context
|
||||
import { StoreContext } from "@/lib/store-context";
|
||||
// plane web stores
|
||||
import { IFeatureFlagsStore } from "@/plane-web/store/feature-flags/feature-flags.store";
|
||||
|
||||
export const useFeatureFlags = (): IFeatureFlagsStore => {
|
||||
const context = useContext(StoreContext);
|
||||
if (context === undefined) throw new Error("useFeatureFlags must be used within StoreProvider");
|
||||
return context.featureFlags;
|
||||
};
|
||||
25
web/ee/hooks/store/use-flag.ts
Normal file
25
web/ee/hooks/store/use-flag.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useContext } from "react";
|
||||
// context
|
||||
import { StoreContext } from "@/lib/store-context";
|
||||
|
||||
export enum E_FEATURE_FLAGS {
|
||||
BULK_OPS = "BULK_OPS",
|
||||
ESTIMATE_WITH_TIME = "ESTIMATE_WITH_TIME",
|
||||
ISSUE_TYPE_DISPLAY = "ISSUE_TYPE_DISPLAY",
|
||||
ISSUE_TYPE_SETTINGS = "ISSUE_TYPE_SETTINGS",
|
||||
OIDC_SAML_AUTH = "OIDC_SAML_AUTH",
|
||||
PAGE_ISSUE_EMBEDS = "PAGE_ISSUE_EMBEDS",
|
||||
PAGE_PUBLISH = "PAGE_PUBLISH",
|
||||
VIEW_ACCESS_PRIVATE = "VIEW_ACCESS_PRIVATE",
|
||||
VIEW_LOCK = "VIEW_LOCK",
|
||||
VIEW_PUBLISH = "VIEW_PUBLISH",
|
||||
WORKSPACE_ACTIVE_CYCLES = "WORKSPACE_ACTIVE_CYCLES",
|
||||
WORKSPACE_PAGES = "WORKSPACE_PAGES",
|
||||
WORK_LOGS = "WORK_LOGS",
|
||||
}
|
||||
|
||||
export const useFlag = (flag: keyof typeof E_FEATURE_FLAGS, defaultValue: boolean = false): boolean => {
|
||||
const context = useContext(StoreContext);
|
||||
if (context === undefined) throw new Error("useFlag must be used within StoreProvider");
|
||||
return context.featureFlags.flags[E_FEATURE_FLAGS[flag]] ?? defaultValue;
|
||||
};
|
||||
@@ -1 +1,8 @@
|
||||
export const useBulkOperationStatus = () => true;
|
||||
// plane web hooks
|
||||
import { useFlag } from "@/plane-web/hooks/store/use-flag";
|
||||
|
||||
export const useBulkOperationStatus = () => {
|
||||
// store hooks
|
||||
const isBulkOpsEnabled = useFlag("BULK_OPS");
|
||||
return isBulkOpsEnabled;
|
||||
};
|
||||
|
||||
@@ -5,13 +5,18 @@ import { TPageEmbedResponse, TPageEmbedType } from "@plane/types";
|
||||
// ui
|
||||
import { PriorityIcon } from "@plane/ui";
|
||||
// plane web components
|
||||
import { IssueEmbedCard } from "@/plane-web/components/pages";
|
||||
import { IssueEmbedCard, IssueEmbedUpgradeCard } from "@/plane-web/components/pages";
|
||||
// plane web hooks
|
||||
import { useFlag } from "@/plane-web/hooks/store/use-flag";
|
||||
// services
|
||||
import { ProjectPageService } from "@/services/page";
|
||||
|
||||
const pageService = new ProjectPageService();
|
||||
|
||||
export const useIssueEmbed = (workspaceSlug: string, projectId: string, queryType: TPageEmbedType = "issue") => {
|
||||
// store hooks
|
||||
const isIssueEmbedEnabled = useFlag("PAGE_ISSUE_EMBEDS");
|
||||
|
||||
const fetchIssues = async (searchQuery: string): Promise<TEmbedItem[]> => {
|
||||
const response = await pageService.searchEmbed<TPageEmbedResponse[]>(workspaceSlug, projectId, {
|
||||
query_type: queryType,
|
||||
@@ -52,6 +57,8 @@ export const useIssueEmbed = (workspaceSlug: string, projectId: string, queryTyp
|
||||
return <IssueEmbedCard issueId={issueId} projectId={resolvedProjectId} workspaceSlug={resolvedWorkspaceSlug} />;
|
||||
};
|
||||
|
||||
const upgradeCallback = () => <IssueEmbedUpgradeCard />;
|
||||
|
||||
const issueEmbedProps: TEmbedConfig["issue"] = {
|
||||
searchCallback,
|
||||
widgetCallback,
|
||||
@@ -61,8 +68,23 @@ export const useIssueEmbed = (workspaceSlug: string, projectId: string, queryTyp
|
||||
widgetCallback,
|
||||
};
|
||||
|
||||
const issueEmbedUpgradeProps: TEmbedConfig["issue"] = {
|
||||
widgetCallback: upgradeCallback,
|
||||
};
|
||||
|
||||
const issueEmbedReadOnlyUpgradeProps: TReadOnlyEmbedConfig["issue"] = {
|
||||
widgetCallback: upgradeCallback,
|
||||
};
|
||||
|
||||
if (isIssueEmbedEnabled) {
|
||||
return {
|
||||
issueEmbedProps,
|
||||
issueEmbedReadOnlyProps,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
issueEmbedProps,
|
||||
issueEmbedReadOnlyProps,
|
||||
issueEmbedProps: issueEmbedUpgradeProps,
|
||||
issueEmbedReadOnlyProps: issueEmbedReadOnlyUpgradeProps,
|
||||
};
|
||||
};
|
||||
|
||||
22
web/ee/services/feature-flag.service.ts
Normal file
22
web/ee/services/feature-flag.service.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
// services
|
||||
import { APIService } from "@/services/api.service";
|
||||
|
||||
export type TFeatureFlagsResponse = {
|
||||
values: {
|
||||
[featureFlag: string]: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export class FeatureFlagService extends APIService {
|
||||
constructor() {
|
||||
super("");
|
||||
}
|
||||
|
||||
async getFeatureFlags(data = {}): Promise<TFeatureFlagsResponse> {
|
||||
return this.post(`/flags/`, data)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ export class PaymentService extends APIService {
|
||||
super(API_BASE_URL);
|
||||
}
|
||||
|
||||
listProducts(workspaceSlug: string): Promise<IPaymentProduct[]> {
|
||||
async listProducts(workspaceSlug: string): Promise<IPaymentProduct[]> {
|
||||
return this.get(`/api/payments/workspaces/${workspaceSlug}/products/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
@@ -17,7 +17,7 @@ export class PaymentService extends APIService {
|
||||
});
|
||||
}
|
||||
|
||||
getCurrentWorkspacePaymentLink(workspaceSlug: string, data = {}) {
|
||||
async getCurrentWorkspacePaymentLink(workspaceSlug: string, data = {}) {
|
||||
return this.post(`/api/payments/workspaces/${workspaceSlug}/payment-link/`, data)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
@@ -25,7 +25,7 @@ export class PaymentService extends APIService {
|
||||
});
|
||||
}
|
||||
|
||||
getWorkspaceCurrentPlane(workspaceSlug: string): Promise<IWorkspaceProductSubscription> {
|
||||
async getWorkspaceCurrentPlan(workspaceSlug: string): Promise<IWorkspaceProductSubscription> {
|
||||
return this.get(`/api/payments/workspaces/${workspaceSlug}/current-plan/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
@@ -33,6 +33,14 @@ export class PaymentService extends APIService {
|
||||
});
|
||||
}
|
||||
|
||||
async getWorkspaceSubscriptionPageLink(workspaceSlug: string) {
|
||||
return this.post(`/api/payments/workspaces/${workspaceSlug}/subscriptions/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async getPaymentLink(data = {}) {
|
||||
return this.post(`/api/payments/website/payment-link/`, data)
|
||||
.then((response) => response?.data)
|
||||
|
||||
50
web/ee/store/feature-flags/feature-flags.store.ts
Normal file
50
web/ee/store/feature-flags/feature-flags.store.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { set } from "lodash";
|
||||
import { action, makeObservable, observable, runInAction } from "mobx";
|
||||
// services
|
||||
import { FeatureFlagService, TFeatureFlagsResponse } from "@/plane-web/services/feature-flag.service";
|
||||
// plane web store
|
||||
|
||||
const featureFlagService = new FeatureFlagService();
|
||||
|
||||
type TFeatureFlagsMaps = {
|
||||
[featureFlag: string]: boolean;
|
||||
};
|
||||
|
||||
export interface IFeatureFlagsStore {
|
||||
loader: boolean;
|
||||
flags: TFeatureFlagsMaps;
|
||||
fetchFeatureFlags: (workspaceSlug: string, userId: string) => Promise<TFeatureFlagsResponse>;
|
||||
}
|
||||
|
||||
export class FeatureFlagsStore implements IFeatureFlagsStore {
|
||||
loader = false;
|
||||
flags: TFeatureFlagsMaps = {};
|
||||
|
||||
constructor() {
|
||||
makeObservable(this, {
|
||||
loader: observable.ref,
|
||||
flags: observable,
|
||||
fetchFeatureFlags: action,
|
||||
});
|
||||
}
|
||||
|
||||
fetchFeatureFlags = async (workspaceSlug: string, userId: string) => {
|
||||
try {
|
||||
set(this, "loader", true);
|
||||
const response = await featureFlagService.getFeatureFlags({ workspace_slug: workspaceSlug, user_id: userId });
|
||||
runInAction(() => {
|
||||
if (response.values) {
|
||||
Object.keys(response.values).forEach((key) => {
|
||||
set(this.flags, key, response.values[key]);
|
||||
});
|
||||
}
|
||||
set(this, "loader", false);
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
set(this, "loader", false);
|
||||
console.error("Error fetching feature flags", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,23 +1,26 @@
|
||||
// plane web store
|
||||
import { FeatureFlagsStore, IFeatureFlagsStore } from "@/plane-web/store/feature-flags/feature-flags.store";
|
||||
import { IPublishPageStore, PublishPageStore } from "@/plane-web/store/pages/publish-page.store";
|
||||
import { IWorkspacePageStore, WorkspacePageStore } from "@/plane-web/store/pages/workspace-page.store";
|
||||
// store
|
||||
import {
|
||||
IWorkspaceSubscriptionStore,
|
||||
WorkspaceSubscriptionStore,
|
||||
} from "@/plane-web/store/subscription/subscription.store";
|
||||
// store
|
||||
import { CoreRootStore } from "@/store/root.store";
|
||||
|
||||
export class RootStore extends CoreRootStore {
|
||||
workspacePages: IWorkspacePageStore;
|
||||
publishPage: IPublishPageStore;
|
||||
workspaceSubscription: IWorkspaceSubscriptionStore;
|
||||
featureFlags: IFeatureFlagsStore;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.workspacePages = new WorkspacePageStore(this);
|
||||
this.publishPage = new PublishPageStore(this);
|
||||
this.workspaceSubscription = new WorkspaceSubscriptionStore(this);
|
||||
this.featureFlags = new FeatureFlagsStore();
|
||||
}
|
||||
|
||||
resetOnSignOut() {
|
||||
@@ -25,5 +28,6 @@ export class RootStore extends CoreRootStore {
|
||||
this.workspacePages = new WorkspacePageStore(this);
|
||||
this.publishPage = new PublishPageStore(this);
|
||||
this.workspaceSubscription = new WorkspaceSubscriptionStore(this);
|
||||
this.featureFlags = new FeatureFlagsStore();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { set } from "lodash";
|
||||
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
||||
// types
|
||||
import { IWorkspaceProductSubscription, TProductSubscriptionType } from "@plane/types";
|
||||
import { IWorkspaceProductSubscription } from "@plane/types";
|
||||
// services
|
||||
import { PaymentService } from "@/plane-web/services/payment.service";
|
||||
// plane web store
|
||||
@@ -10,41 +10,56 @@ import { RootStore } from "@/plane-web/store/root.store";
|
||||
const paymentService = new PaymentService();
|
||||
|
||||
type TWorkspaceSubscriptionMap = {
|
||||
[workspaceSlug: string]: TProductSubscriptionType;
|
||||
[workspaceSlug: string]: IWorkspaceProductSubscription;
|
||||
};
|
||||
|
||||
export interface IWorkspaceSubscriptionStore {
|
||||
subscribedPlan: TWorkspaceSubscriptionMap;
|
||||
currentWorkspaceSubscribedPlan: TProductSubscriptionType | undefined;
|
||||
isProPlanModalOpen: boolean;
|
||||
currentWorkspaceSubscribedPlanDetail: IWorkspaceProductSubscription | undefined;
|
||||
toggleProPlanModal: (value?: boolean) => void;
|
||||
fetchWorkspaceSubscribedPlan: (workspaceSlug: string) => Promise<IWorkspaceProductSubscription>;
|
||||
}
|
||||
|
||||
export class WorkspaceSubscriptionStore implements IWorkspaceSubscriptionStore {
|
||||
subscribedPlan: TWorkspaceSubscriptionMap = {};
|
||||
isProPlanModalOpen = false;
|
||||
|
||||
constructor(private rootStore: RootStore) {
|
||||
makeObservable(this, {
|
||||
subscribedPlan: observable,
|
||||
currentWorkspaceSubscribedPlan: computed,
|
||||
isProPlanModalOpen: observable.ref,
|
||||
currentWorkspaceSubscribedPlanDetail: computed,
|
||||
toggleProPlanModal: action,
|
||||
fetchWorkspaceSubscribedPlan: action,
|
||||
});
|
||||
}
|
||||
|
||||
get currentWorkspaceSubscribedPlan() {
|
||||
get currentWorkspaceSubscribedPlanDetail() {
|
||||
if (!this.rootStore.router.workspaceSlug) return undefined;
|
||||
return this.subscribedPlan[this.rootStore.router.workspaceSlug] || undefined;
|
||||
}
|
||||
|
||||
toggleProPlanModal = (value?: boolean) => {
|
||||
this.isProPlanModalOpen = value ?? !this.isProPlanModalOpen;
|
||||
};
|
||||
|
||||
fetchWorkspaceSubscribedPlan = async (workspaceSlug: string) => {
|
||||
try {
|
||||
const response = await paymentService.getWorkspaceCurrentPlane(workspaceSlug);
|
||||
const response = await paymentService.getWorkspaceCurrentPlan(workspaceSlug);
|
||||
runInAction(() => {
|
||||
set(this.subscribedPlan, workspaceSlug, response?.product || "FREE");
|
||||
set(this.subscribedPlan, workspaceSlug, {
|
||||
product: response?.product || "FREE",
|
||||
current_period_end_date: response?.current_period_end_date,
|
||||
});
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
runInAction(() => {
|
||||
set(this.subscribedPlan, workspaceSlug, "FREE");
|
||||
set(this.subscribedPlan, workspaceSlug, {
|
||||
product: "FREE",
|
||||
current_period_end_date: null,
|
||||
});
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -81,6 +81,15 @@ const nextConfig = {
|
||||
destination: `${GOD_MODE_BASE_URL}/:path*`,
|
||||
});
|
||||
}
|
||||
|
||||
if (process.env.NEXT_PUBLIC_FEATURE_FLAG_SERVER_BASE_URL) {
|
||||
const FEATURE_FLAG_SERVER_BASE_URL = process.env.NEXT_PUBLIC_FEATURE_FLAG_SERVER_BASE_URL;
|
||||
rewrites.push({
|
||||
source: "/flags/",
|
||||
destination: `${FEATURE_FLAG_SERVER_BASE_URL}/api/feature-flags/`,
|
||||
});
|
||||
}
|
||||
|
||||
return rewrites;
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user