From 5b858f2963cf74ae81079c78bfedc24fbe245af5 Mon Sep 17 00:00:00 2001 From: Yunus M Date: Mon, 16 Oct 2023 23:57:44 +0530 Subject: [PATCH] Billing UI (#3711) * feat: integrate billing api and wire up billing ui * feat: show billing to admin only if on plans other than basic plan * feat: show billing to admin only if on plans other than basic plan * feat: update notfound snapshot * chore: fix billing sidenav logic * chore: fix several bugs * chore: backend fix for billing * fix: window.open pop blocker issue and error ui (#3750) --------- Co-authored-by: Srikanth Chekuri Co-authored-by: Rajat Dabade --- ee/query-service/app/api/license.go | 15 +- ee/query-service/model/license.go | 12 +- frontend/public/Images/notFound404.png | Bin 0 -> 43988 bytes frontend/public/locales/en-GB/titles.json | 78 ++-- frontend/public/locales/en/titles.json | 78 ++-- frontend/src/AppRoutes/Private.tsx | 130 ++++-- frontend/src/AppRoutes/index.tsx | 17 +- frontend/src/AppRoutes/pageComponents.ts | 9 + frontend/src/AppRoutes/routes.ts | 17 + frontend/src/api/billing/checkout.ts | 31 ++ frontend/src/api/billing/getUsage.ts | 35 ++ frontend/src/api/dashboard/create.ts | 4 +- frontend/src/api/licenses/getAll.ts | 2 +- frontend/src/assets/NotFound.tsx | 266 +---------- .../__snapshots__/NotFound.test.tsx.snap | 271 +---------- frontend/src/constants/reactQueryKeys.ts | 1 + frontend/src/constants/routes.ts | 2 + frontend/src/container/AppLayout/index.tsx | 3 +- .../BillingContainer.styles.scss | 36 ++ .../BillingContainer/BillingContainer.tsx | 432 ++++++++++++++++++ .../src/container/Header/Header.styles.scss | 12 + .../container/Header/ManageLicense/index.tsx | 2 +- frontend/src/container/Header/index.tsx | 144 ++++-- .../src/container/Licenses/ListLicenses.tsx | 3 +- frontend/src/container/Licenses/index.tsx | 2 +- frontend/src/container/SideNav/SideNav.tsx | 16 +- frontend/src/container/SideNav/config.ts | 1 + frontend/src/container/SideNav/menuItems.tsx | 6 + .../container/TopNav/Breadcrumbs/index.tsx | 4 +- .../TopNav/DateTimeSelection/config.ts | 2 + frontend/src/hooks/useLicense/constant.ts | 1 + frontend/src/hooks/useUsage/useUsage.tsx | 25 + .../src/pages/Billing/BillingPage.styles.scss | 5 + frontend/src/pages/Billing/BillingPage.tsx | 13 + frontend/src/pages/Billing/index.tsx | 3 + .../WorkspaceLocked.styles.scss | 19 + .../pages/WorkspaceLocked/WorkspaceLocked.tsx | 97 ++++ frontend/src/pages/WorkspaceLocked/index.tsx | 3 + frontend/src/types/api/billing/checkout.ts | 9 + frontend/src/types/api/licenses/getAll.ts | 10 +- frontend/src/utils/permission/index.ts | 4 +- 41 files changed, 1104 insertions(+), 716 deletions(-) create mode 100644 frontend/public/Images/notFound404.png create mode 100644 frontend/src/api/billing/checkout.ts create mode 100644 frontend/src/api/billing/getUsage.ts create mode 100644 frontend/src/container/BillingContainer/BillingContainer.styles.scss create mode 100644 frontend/src/container/BillingContainer/BillingContainer.tsx create mode 100644 frontend/src/container/Header/Header.styles.scss create mode 100644 frontend/src/hooks/useUsage/useUsage.tsx create mode 100644 frontend/src/pages/Billing/BillingPage.styles.scss create mode 100644 frontend/src/pages/Billing/BillingPage.tsx create mode 100644 frontend/src/pages/Billing/index.tsx create mode 100644 frontend/src/pages/WorkspaceLocked/WorkspaceLocked.styles.scss create mode 100644 frontend/src/pages/WorkspaceLocked/WorkspaceLocked.tsx create mode 100644 frontend/src/pages/WorkspaceLocked/index.tsx create mode 100644 frontend/src/types/api/billing/checkout.ts diff --git a/ee/query-service/app/api/license.go b/ee/query-service/app/api/license.go index a24ba122d2..c125fd10d1 100644 --- a/ee/query-service/app/api/license.go +++ b/ee/query-service/app/api/license.go @@ -30,6 +30,7 @@ type details struct { Total float64 `json:"total"` Breakdown []usageResponse `json:"breakdown"` BaseFee float64 `json:"baseFee"` + BillTotal float64 `json:"billTotal"` } type billingDetails struct { @@ -147,11 +148,13 @@ func (ah *APIHandler) listLicensesV2(w http.ResponseWriter, r *http.Request) { } resp := model.Licenses{ - TrialStart: -1, - TrialEnd: -1, - OnTrial: false, - WorkSpaceBlock: false, - Licenses: licenses, + TrialStart: -1, + TrialEnd: -1, + OnTrial: false, + WorkSpaceBlock: false, + TrialConvertedToSubscription: false, + GracePeriodEnd: -1, + Licenses: licenses, } var currentActiveLicenseKey string @@ -216,6 +219,8 @@ func (ah *APIHandler) listLicensesV2(w http.ResponseWriter, r *http.Request) { resp.TrialEnd = trialRespData.Data.TrialEnd resp.OnTrial = trialRespData.Data.OnTrial resp.WorkSpaceBlock = trialRespData.Data.WorkSpaceBlock + resp.TrialConvertedToSubscription = trialRespData.Data.TrialConvertedToSubscription + resp.GracePeriodEnd = trialRespData.Data.GracePeriodEnd ah.Respond(w, resp) } diff --git a/ee/query-service/model/license.go b/ee/query-service/model/license.go index 3ba89cf456..7ad349c9b7 100644 --- a/ee/query-service/model/license.go +++ b/ee/query-service/model/license.go @@ -91,11 +91,13 @@ func (l *License) ParseFeatures() { } type Licenses struct { - TrialStart int64 `json:"trialStart"` - TrialEnd int64 `json:"trialEnd"` - OnTrial bool `json:"onTrial"` - WorkSpaceBlock bool `json:"workSpaceBlock"` - Licenses []License `json:"licenses"` + TrialStart int64 `json:"trialStart"` + TrialEnd int64 `json:"trialEnd"` + OnTrial bool `json:"onTrial"` + WorkSpaceBlock bool `json:"workSpaceBlock"` + TrialConvertedToSubscription bool `json:"trialConvertedToSubscription"` + GracePeriodEnd int64 `json:"gracePeriodEnd"` + Licenses []License `json:"licenses"` } type SubscriptionServerResp struct { diff --git a/frontend/public/Images/notFound404.png b/frontend/public/Images/notFound404.png new file mode 100644 index 0000000000000000000000000000000000000000..f80372413856425983d078ae25e1aac63272e8ad GIT binary patch literal 43988 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 literal 0 HcmV?d00001 diff --git a/frontend/public/locales/en-GB/titles.json b/frontend/public/locales/en-GB/titles.json index d61817e520..f5641d96b6 100644 --- a/frontend/public/locales/en-GB/titles.json +++ b/frontend/public/locales/en-GB/titles.json @@ -1,38 +1,40 @@ -{ - "SIGN_UP": "SigNoz | Sign Up", - "LOGIN": "SigNoz | Login", - "GET_STARTED": "SigNoz | Get Started", - "SERVICE_METRICS": "SigNoz | Service Metrics", - "SERVICE_MAP": "SigNoz | Service Map", - "TRACE": "SigNoz | Trace", - "TRACE_DETAIL": "SigNoz | Trace Detail", - "TRACES_EXPLORER": "SigNoz | Traces Explorer", - "SETTINGS": "SigNoz | Settings", - "USAGE_EXPLORER": "SigNoz | Usage Explorer", - "APPLICATION": "SigNoz | Home", - "ALL_DASHBOARD": "SigNoz | All Dashboards", - "DASHBOARD": "SigNoz | Dashboard", - "DASHBOARD_WIDGET": "SigNoz | Dashboard Widget", - "EDIT_ALERTS": "SigNoz | Edit Alerts", - "LIST_ALL_ALERT": "SigNoz | All Alerts", - "ALERTS_NEW": "SigNoz | New Alert", - "ALL_CHANNELS": "SigNoz | All Channels", - "CHANNELS_NEW": "SigNoz | New Channel", - "CHANNELS_EDIT": "SigNoz | Edit Channel", - "ALL_ERROR": "SigNoz | All Errors", - "ERROR_DETAIL": "SigNoz | Error Detail", - "VERSION": "SigNoz | Version", - "MY_SETTINGS": "SigNoz | My Settings", - "ORG_SETTINGS": "SigNoz | Organization Settings", - "INGESTION_SETTINGS": "SigNoz | Ingestion Settings", - "SOMETHING_WENT_WRONG": "SigNoz | Something Went Wrong", - "UN_AUTHORIZED": "SigNoz | Unauthorized", - "NOT_FOUND": "SigNoz | Page Not Found", - "LOGS": "SigNoz | Logs", - "LOGS_EXPLORER": "SigNoz | Logs Explorer", - "LIVE_LOGS": "SigNoz | Live Logs", - "HOME_PAGE": "Open source Observability Platform | SigNoz", - "PASSWORD_RESET": "SigNoz | Password Reset", - "LIST_LICENSES": "SigNoz | List of Licenses", - "DEFAULT": "Open source Observability Platform | SigNoz" -} +{ + "SIGN_UP": "SigNoz | Sign Up", + "LOGIN": "SigNoz | Login", + "GET_STARTED": "SigNoz | Get Started", + "SERVICE_METRICS": "SigNoz | Service Metrics", + "SERVICE_MAP": "SigNoz | Service Map", + "TRACE": "SigNoz | Trace", + "TRACE_DETAIL": "SigNoz | Trace Detail", + "TRACES_EXPLORER": "SigNoz | Traces Explorer", + "SETTINGS": "SigNoz | Settings", + "USAGE_EXPLORER": "SigNoz | Usage Explorer", + "APPLICATION": "SigNoz | Home", + "BILLING": "SigNoz | Billing", + "ALL_DASHBOARD": "SigNoz | All Dashboards", + "DASHBOARD": "SigNoz | Dashboard", + "DASHBOARD_WIDGET": "SigNoz | Dashboard Widget", + "EDIT_ALERTS": "SigNoz | Edit Alerts", + "LIST_ALL_ALERT": "SigNoz | All Alerts", + "ALERTS_NEW": "SigNoz | New Alert", + "ALL_CHANNELS": "SigNoz | All Channels", + "CHANNELS_NEW": "SigNoz | New Channel", + "CHANNELS_EDIT": "SigNoz | Edit Channel", + "ALL_ERROR": "SigNoz | All Errors", + "ERROR_DETAIL": "SigNoz | Error Detail", + "VERSION": "SigNoz | Version", + "MY_SETTINGS": "SigNoz | My Settings", + "ORG_SETTINGS": "SigNoz | Organization Settings", + "INGESTION_SETTINGS": "SigNoz | Ingestion Settings", + "SOMETHING_WENT_WRONG": "SigNoz | Something Went Wrong", + "UN_AUTHORIZED": "SigNoz | Unauthorized", + "NOT_FOUND": "SigNoz | Page Not Found", + "LOGS": "SigNoz | Logs", + "LOGS_EXPLORER": "SigNoz | Logs Explorer", + "LIVE_LOGS": "SigNoz | Live Logs", + "HOME_PAGE": "Open source Observability Platform | SigNoz", + "PASSWORD_RESET": "SigNoz | Password Reset", + "LIST_LICENSES": "SigNoz | List of Licenses", + "WORKSPACE_LOCKED": "SigNoz | Workspace Locked", + "DEFAULT": "Open source Observability Platform | SigNoz" +} diff --git a/frontend/public/locales/en/titles.json b/frontend/public/locales/en/titles.json index 26e2141d38..75c8d73bcf 100644 --- a/frontend/public/locales/en/titles.json +++ b/frontend/public/locales/en/titles.json @@ -1,38 +1,40 @@ -{ - "SIGN_UP": "SigNoz | Sign Up", - "LOGIN": "SigNoz | Login", - "SERVICE_METRICS": "SigNoz | Service Metrics", - "SERVICE_MAP": "SigNoz | Service Map", - "GET_STARTED": "SigNoz | Get Started", - "TRACE": "SigNoz | Trace", - "TRACE_DETAIL": "SigNoz | Trace Detail", - "TRACES_EXPLORER": "SigNoz | Traces Explorer", - "SETTINGS": "SigNoz | Settings", - "USAGE_EXPLORER": "SigNoz | Usage Explorer", - "APPLICATION": "SigNoz | Home", - "ALL_DASHBOARD": "SigNoz | All Dashboards", - "DASHBOARD": "SigNoz | Dashboard", - "DASHBOARD_WIDGET": "SigNoz | Dashboard Widget", - "EDIT_ALERTS": "SigNoz | Edit Alerts", - "LIST_ALL_ALERT": "SigNoz | All Alerts", - "ALERTS_NEW": "SigNoz | New Alert", - "ALL_CHANNELS": "SigNoz | All Channels", - "CHANNELS_NEW": "SigNoz | New Channel", - "CHANNELS_EDIT": "SigNoz | Edit Channel", - "ALL_ERROR": "SigNoz | All Errors", - "ERROR_DETAIL": "SigNoz | Error Detail", - "VERSION": "SigNoz | Version", - "MY_SETTINGS": "SigNoz | My Settings", - "ORG_SETTINGS": "SigNoz | Organization Settings", - "INGESTION_SETTINGS": "SigNoz | Ingestion Settings", - "SOMETHING_WENT_WRONG": "SigNoz | Something Went Wrong", - "UN_AUTHORIZED": "SigNoz | Unauthorized", - "NOT_FOUND": "SigNoz | Page Not Found", - "LOGS": "SigNoz | Logs", - "LOGS_EXPLORER": "SigNoz | Logs Explorer", - "LIVE_LOGS": "SigNoz | Live Logs", - "HOME_PAGE": "Open source Observability Platform | SigNoz", - "PASSWORD_RESET": "SigNoz | Password Reset", - "LIST_LICENSES": "SigNoz | List of Licenses", - "DEFAULT": "Open source Observability Platform | SigNoz" -} +{ + "SIGN_UP": "SigNoz | Sign Up", + "LOGIN": "SigNoz | Login", + "SERVICE_METRICS": "SigNoz | Service Metrics", + "SERVICE_MAP": "SigNoz | Service Map", + "GET_STARTED": "SigNoz | Get Started", + "TRACE": "SigNoz | Trace", + "TRACE_DETAIL": "SigNoz | Trace Detail", + "TRACES_EXPLORER": "SigNoz | Traces Explorer", + "SETTINGS": "SigNoz | Settings", + "USAGE_EXPLORER": "SigNoz | Usage Explorer", + "APPLICATION": "SigNoz | Home", + "BILLING": "SigNoz | Billing", + "ALL_DASHBOARD": "SigNoz | All Dashboards", + "DASHBOARD": "SigNoz | Dashboard", + "DASHBOARD_WIDGET": "SigNoz | Dashboard Widget", + "EDIT_ALERTS": "SigNoz | Edit Alerts", + "LIST_ALL_ALERT": "SigNoz | All Alerts", + "ALERTS_NEW": "SigNoz | New Alert", + "ALL_CHANNELS": "SigNoz | All Channels", + "CHANNELS_NEW": "SigNoz | New Channel", + "CHANNELS_EDIT": "SigNoz | Edit Channel", + "ALL_ERROR": "SigNoz | All Errors", + "ERROR_DETAIL": "SigNoz | Error Detail", + "VERSION": "SigNoz | Version", + "MY_SETTINGS": "SigNoz | My Settings", + "ORG_SETTINGS": "SigNoz | Organization Settings", + "INGESTION_SETTINGS": "SigNoz | Ingestion Settings", + "SOMETHING_WENT_WRONG": "SigNoz | Something Went Wrong", + "UN_AUTHORIZED": "SigNoz | Unauthorized", + "NOT_FOUND": "SigNoz | Page Not Found", + "LOGS": "SigNoz | Logs", + "LOGS_EXPLORER": "SigNoz | Logs Explorer", + "LIVE_LOGS": "SigNoz | Live Logs", + "HOME_PAGE": "Open source Observability Platform | SigNoz", + "PASSWORD_RESET": "SigNoz | Password Reset", + "LIST_LICENSES": "SigNoz | List of Licenses", + "WORKSPACE_LOCKED": "SigNoz | Workspace Locked", + "DEFAULT": "Open source Observability Platform | SigNoz" +} diff --git a/frontend/src/AppRoutes/Private.tsx b/frontend/src/AppRoutes/Private.tsx index ddfb072d02..70f8cccf04 100644 --- a/frontend/src/AppRoutes/Private.tsx +++ b/frontend/src/AppRoutes/Private.tsx @@ -5,6 +5,7 @@ import { Logout } from 'api/utils'; import Spinner from 'components/Spinner'; import { LOCALSTORAGE } from 'constants/localStorage'; import ROUTES from 'constants/routes'; +import useLicense from 'hooks/useLicense'; import { useNotifications } from 'hooks/useNotifications'; import history from 'lib/history'; import { ReactChild, useEffect, useMemo } from 'react'; @@ -37,13 +38,18 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element { ), [pathname], ); + + const { data: licensesData } = useLicense(); + const { + user, isUserFetching, isUserFetchingError, isLoggedIn: isLoggedInState, } = useSelector((state) => state.app); const { t } = useTranslation(['common']); + const localStorageUserAuthToken = getInitialUserTokenRefreshToken(); const dispatch = useDispatch>(); @@ -51,6 +57,9 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element { const currentRoute = mapRoutes.get('current'); + const isLocalStorageLoggedIn = + getLocalStorageApi(LOCALSTORAGE.IS_LOGGED_IN) === 'true'; + const navigateToLoginIfNotLoggedIn = (isLoggedIn = isLoggedInState): void => { dispatch({ type: UPDATE_USER_IS_FETCH, @@ -64,58 +73,87 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element { } }; + const handleUserLoginIfTokenPresent = async ( + key: keyof typeof ROUTES, + ): Promise => { + if (localStorageUserAuthToken?.refreshJwt) { + // localstorage token is present + + // renew web access token + const response = await loginApi({ + refreshToken: localStorageUserAuthToken?.refreshJwt, + }); + + if (response.statusCode === 200) { + const route = routePermission[key]; + + // get all resource and put it over redux + const userResponse = await afterLogin( + response.payload.userId, + response.payload.accessJwt, + response.payload.refreshJwt, + ); + + if ( + userResponse && + route.find((e) => e === userResponse.payload.role) === undefined + ) { + history.push(ROUTES.UN_AUTHORIZED); + } + } else { + Logout(); + + notifications.error({ + message: response.error || t('something_went_wrong'), + }); + } + } + }; + + const handlePrivateRoutes = async ( + key: keyof typeof ROUTES, + ): Promise => { + if ( + localStorageUserAuthToken && + localStorageUserAuthToken.refreshJwt && + user?.userId === '' + ) { + handleUserLoginIfTokenPresent(key); + } else { + // user does have localstorage values + + navigateToLoginIfNotLoggedIn(isLocalStorageLoggedIn); + } + }; + + const navigateToWorkSpaceBlocked = (route: any): void => { + const { path } = route; + + if (path && path !== ROUTES.WORKSPACE_LOCKED) { + history.push(ROUTES.WORKSPACE_LOCKED); + } + + dispatch({ + type: UPDATE_USER_IS_FETCH, + payload: { + isUserFetching: false, + }, + }); + }; + // eslint-disable-next-line sonarjs/cognitive-complexity useEffect(() => { (async (): Promise => { try { - const isLocalStorageLoggedIn = - getLocalStorageApi(LOCALSTORAGE.IS_LOGGED_IN) === 'true'; + const shouldBlockWorkspace = licensesData?.payload?.workSpaceBlock; + if (currentRoute) { const { isPrivate, key } = currentRoute; - if (isPrivate) { - const localStorageUserAuthToken = getInitialUserTokenRefreshToken(); - - if ( - localStorageUserAuthToken && - localStorageUserAuthToken.refreshJwt && - isUserFetching - ) { - // localstorage token is present - const { refreshJwt } = localStorageUserAuthToken; - - // renew web access token - const response = await loginApi({ - refreshToken: refreshJwt, - }); - - if (response.statusCode === 200) { - const route = routePermission[key]; - - // get all resource and put it over redux - const userResponse = await afterLogin( - response.payload.userId, - response.payload.accessJwt, - response.payload.refreshJwt, - ); - - if ( - userResponse && - route.find((e) => e === userResponse.payload.role) === undefined - ) { - history.push(ROUTES.UN_AUTHORIZED); - } - } else { - Logout(); - - notifications.error({ - message: response.error || t('something_went_wrong'), - }); - } - } else { - // user does have localstorage values - navigateToLoginIfNotLoggedIn(isLocalStorageLoggedIn); - } + if (shouldBlockWorkspace) { + navigateToWorkSpaceBlocked(currentRoute); + } else if (isPrivate) { + handlePrivateRoutes(key); } else { // no need to fetch the user and make user fetching false @@ -145,7 +183,7 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element { history.push(ROUTES.SOMETHING_WENT_WRONG); } })(); - }, [dispatch, isLoggedInState, currentRoute]); + }, [dispatch, isLoggedInState, currentRoute, licensesData]); if (isUserFetchingError) { return ; diff --git a/frontend/src/AppRoutes/index.tsx b/frontend/src/AppRoutes/index.tsx index c2a0db3da1..a2330d1aeb 100644 --- a/frontend/src/AppRoutes/index.tsx +++ b/frontend/src/AppRoutes/index.tsx @@ -9,6 +9,7 @@ import ROUTES from 'constants/routes'; import AppLayout from 'container/AppLayout'; import { useThemeConfig } from 'hooks/useDarkMode'; import useGetFeatureFlag from 'hooks/useGetFeatureFlag'; +import useLicense, { LICENSE_PLAN_KEY } from 'hooks/useLicense'; import { NotificationProvider } from 'hooks/useNotifications'; import { ResourceProvider } from 'hooks/useResourceAttribute'; import history from 'lib/history'; @@ -29,8 +30,9 @@ import defaultRoutes from './routes'; function App(): JSX.Element { const themeConfig = useThemeConfig(); + const { data } = useLicense(); const [routes, setRoutes] = useState(defaultRoutes); - const { isLoggedIn: isLoggedInState, user } = useSelector< + const { role, isLoggedIn: isLoggedInState, user } = useSelector< AppState, AppReducer >((state) => state.app); @@ -78,6 +80,12 @@ function App(): JSX.Element { } }); + const isOnBasicPlan = + data?.payload?.licenses?.some( + (license) => + license.isCurrent && license.planKey === LICENSE_PLAN_KEY.BASIC_PLAN, + ) || data?.payload?.licenses === null; + useEffect(() => { const isIdentifiedUser = getLocalStorageApi(LOCALSTORAGE.IS_IDENTIFIED_USER); @@ -97,8 +105,13 @@ function App(): JSX.Element { window.clarity('identify', user.email, user.name); } + + if (isOnBasicPlan || (isLoggedInState && role && role !== 'ADMIN')) { + const newRoutes = routes.filter((route) => route?.path !== ROUTES.BILLING); + setRoutes(newRoutes); + } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isLoggedInState, user]); + }, [isLoggedInState, isOnBasicPlan, user]); useEffect(() => { trackPageView(pathname); diff --git a/frontend/src/AppRoutes/pageComponents.ts b/frontend/src/AppRoutes/pageComponents.ts index 3852153da8..ad33f3a83c 100644 --- a/frontend/src/AppRoutes/pageComponents.ts +++ b/frontend/src/AppRoutes/pageComponents.ts @@ -153,3 +153,12 @@ export const LogsIndexToFields = Loadable( export const PipelinePage = Loadable( () => import(/* webpackChunkName: "Pipelines" */ 'pages/Pipelines'), ); + +export const BillingPage = Loadable( + () => import(/* webpackChunkName: "BillingPage" */ 'pages/Billing'), +); + +export const WorkspaceBlocked = Loadable( + () => + import(/* webpackChunkName: "WorkspaceLocked" */ 'pages/WorkspaceLocked'), +); diff --git a/frontend/src/AppRoutes/routes.ts b/frontend/src/AppRoutes/routes.ts index 0c0f5ae9cb..dfda9f8312 100644 --- a/frontend/src/AppRoutes/routes.ts +++ b/frontend/src/AppRoutes/routes.ts @@ -1,9 +1,11 @@ import ROUTES from 'constants/routes'; +import WorkspaceBlocked from 'pages/WorkspaceLocked'; import { RouteProps } from 'react-router-dom'; import { AllAlertChannels, AllErrors, + BillingPage, CreateAlertChannelAlerts, CreateNewAlerts, DashboardPage, @@ -285,6 +287,21 @@ const routes: AppRoutes[] = [ key: 'PIPELINES', isPrivate: true, }, + + { + path: ROUTES.BILLING, + exact: true, + component: BillingPage, + key: 'BILLING', + isPrivate: true, + }, + { + path: ROUTES.WORKSPACE_LOCKED, + exact: true, + component: WorkspaceBlocked, + isPrivate: false, + key: 'WORKSPACE_LOCKED', + }, ]; export interface AppRoutes { diff --git a/frontend/src/api/billing/checkout.ts b/frontend/src/api/billing/checkout.ts new file mode 100644 index 0000000000..e6c7640629 --- /dev/null +++ b/frontend/src/api/billing/checkout.ts @@ -0,0 +1,31 @@ +import axios from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { + CheckoutRequestPayloadProps, + CheckoutSuccessPayloadProps, +} from 'types/api/billing/checkout'; + +const updateCreditCardApi = async ( + props: CheckoutRequestPayloadProps, +): Promise | ErrorResponse> => { + try { + const response = await axios.post('/checkout', { + licenseKey: props.licenseKey, + successURL: props.successURL, + cancelURL: props.cancelURL, // temp + }); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default updateCreditCardApi; diff --git a/frontend/src/api/billing/getUsage.ts b/frontend/src/api/billing/getUsage.ts new file mode 100644 index 0000000000..1cb5be5640 --- /dev/null +++ b/frontend/src/api/billing/getUsage.ts @@ -0,0 +1,35 @@ +import axios from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import { ErrorResponse, SuccessResponse } from 'types/api'; + +export interface UsageResponsePayloadProps { + billingPeriodStart: Date; + billingPeriodEnd: Date; + details: { + total: number; + baseFee: number; + breakdown: []; + billTotal: number; + }; + discount: number; +} + +const getUsage = async ( + licenseKey: string, +): Promise | ErrorResponse> => { + try { + const response = await axios.get(`/billing?licenseKey=${licenseKey}`); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default getUsage; diff --git a/frontend/src/api/dashboard/create.ts b/frontend/src/api/dashboard/create.ts index 3796eb685e..bf5458ac40 100644 --- a/frontend/src/api/dashboard/create.ts +++ b/frontend/src/api/dashboard/create.ts @@ -4,7 +4,7 @@ import { AxiosError } from 'axios'; import { ErrorResponse, SuccessResponse } from 'types/api'; import { PayloadProps, Props } from 'types/api/dashboard/create'; -const create = async ( +const createDashboard = async ( props: Props, ): Promise | ErrorResponse> => { const url = props.uploadedGrafana ? '/dashboards/grafana' : '/dashboards'; @@ -24,4 +24,4 @@ const create = async ( } }; -export default create; +export default createDashboard; diff --git a/frontend/src/api/licenses/getAll.ts b/frontend/src/api/licenses/getAll.ts index bce8c6b1b6..4782be323f 100644 --- a/frontend/src/api/licenses/getAll.ts +++ b/frontend/src/api/licenses/getAll.ts @@ -1,4 +1,4 @@ -import axios from 'api'; +import { ApiV2Instance as axios } from 'api'; import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; import { AxiosError } from 'axios'; import { ErrorResponse, SuccessResponse } from 'types/api'; diff --git a/frontend/src/assets/NotFound.tsx b/frontend/src/assets/NotFound.tsx index 383435cb6a..b8bf4d0869 100644 --- a/frontend/src/assets/NotFound.tsx +++ b/frontend/src/assets/NotFound.tsx @@ -1,263 +1,13 @@ function NotFound(): JSX.Element { return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + not-found ); } diff --git a/frontend/src/components/NotFound/__snapshots__/NotFound.test.tsx.snap b/frontend/src/components/NotFound/__snapshots__/NotFound.test.tsx.snap index cd16f3163a..5415d86836 100644 --- a/frontend/src/components/NotFound/__snapshots__/NotFound.test.tsx.snap +++ b/frontend/src/components/NotFound/__snapshots__/NotFound.test.tsx.snap @@ -99,272 +99,11 @@ exports[`Not Found page test should render Not Found page without errors 1`] = `
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + not-found
diff --git a/frontend/src/constants/reactQueryKeys.ts b/frontend/src/constants/reactQueryKeys.ts index ec55889516..63fc205d81 100644 --- a/frontend/src/constants/reactQueryKeys.ts +++ b/frontend/src/constants/reactQueryKeys.ts @@ -4,6 +4,7 @@ export const REACT_QUERY_KEY = { GET_ALL_DASHBOARDS: 'GET_ALL_DASHBOARDS', GET_TRIGGERED_ALERTS: 'GET_TRIGGERED_ALERTS', DASHBOARD_BY_ID: 'DASHBOARD_BY_ID', + GET_BILLING_USAGE: 'GET_BILLING_USAGE', GET_FEATURES_FLAGS: 'GET_FEATURES_FLAGS', DELETE_DASHBOARD: 'DELETE_DASHBOARD', LOGS_PIPELINE_PREVIEW: 'LOGS_PIPELINE_PREVIEW', diff --git a/frontend/src/constants/routes.ts b/frontend/src/constants/routes.ts index b156036ce4..a66e7e7b4e 100644 --- a/frontend/src/constants/routes.ts +++ b/frontend/src/constants/routes.ts @@ -38,6 +38,8 @@ const ROUTES = { LOGS_PIPELINE: '/logs-explorer/pipeline', TRACE_EXPLORER: '/trace-explorer', PIPELINES: '/pipelines', + BILLING: '/billing', + WORKSPACE_LOCKED: '/workspace-locked', }; export default ROUTES; diff --git a/frontend/src/container/AppLayout/index.tsx b/frontend/src/container/AppLayout/index.tsx index b4dddf1a7b..b47885cf7b 100644 --- a/frontend/src/container/AppLayout/index.tsx +++ b/frontend/src/container/AppLayout/index.tsx @@ -191,7 +191,8 @@ function AppLayout(props: AppLayoutProps): JSX.Element { const routeKey = useMemo(() => getRouteKey(pathname), [pathname]); const pageTitle = t(routeKey); - const renderFullScreen = pathname === ROUTES.GET_STARTED; + const renderFullScreen = + pathname === ROUTES.GET_STARTED || pathname === ROUTES.WORKSPACE_LOCKED; return ( diff --git a/frontend/src/container/BillingContainer/BillingContainer.styles.scss b/frontend/src/container/BillingContainer/BillingContainer.styles.scss new file mode 100644 index 0000000000..afb9e80253 --- /dev/null +++ b/frontend/src/container/BillingContainer/BillingContainer.styles.scss @@ -0,0 +1,36 @@ +.billing-container { + padding: 16px 0; + width: 100%; + + .billing-summary { + margin: 24px 8px; + } + + .billing-details { + margin: 36px 8px; + } + + .upgrade-plan-benefits { + margin: 0px 8px; + border: 1px solid #333; + border-radius: 5px; + padding: 0 48px; + .plan-benefits { + .plan-benefit { + display: flex; + align-items: center; + gap: 16px; + margin: 16px 0; + } + } + } +} + +.ant-skeleton.ant-skeleton-element.ant-skeleton-active { + width: 100%; + min-width: 100%; +} + +.ant-skeleton.ant-skeleton-element .ant-skeleton-input { + min-width: 100% !important; +} diff --git a/frontend/src/container/BillingContainer/BillingContainer.tsx b/frontend/src/container/BillingContainer/BillingContainer.tsx new file mode 100644 index 0000000000..aa674e780a --- /dev/null +++ b/frontend/src/container/BillingContainer/BillingContainer.tsx @@ -0,0 +1,432 @@ +/* eslint-disable @typescript-eslint/no-loop-func */ +import './BillingContainer.styles.scss'; + +import { CheckCircleOutlined } from '@ant-design/icons'; +import { Button, Col, Row, Skeleton, Table, Tag, Typography } from 'antd'; +import { ColumnsType } from 'antd/es/table'; +import updateCreditCardApi from 'api/billing/checkout'; +import getUsage from 'api/billing/getUsage'; +import { SOMETHING_WENT_WRONG } from 'constants/api'; +import { REACT_QUERY_KEY } from 'constants/reactQueryKeys'; +import useAxiosError from 'hooks/useAxiosError'; +import useLicense from 'hooks/useLicense'; +import { useNotifications } from 'hooks/useNotifications'; +import { useCallback, useEffect, useState } from 'react'; +import { useMutation, useQuery } from 'react-query'; +import { useSelector } from 'react-redux'; +import { AppState } from 'store/reducers'; +import { License } from 'types/api/licenses/def'; +import AppReducer from 'types/reducer/app'; + +interface DataType { + key: string; + name: string; + unit: string; + dataIngested: string; + pricePerUnit: string; + cost: string; +} + +const renderSkeletonInput = (): JSX.Element => ( + +); + +const dummyData: DataType[] = [ + { + key: '1', + name: 'Logs', + unit: '', + dataIngested: '', + pricePerUnit: '', + cost: '', + }, + { + key: '2', + name: 'Traces', + unit: '', + dataIngested: '', + pricePerUnit: '', + cost: '', + }, + { + key: '3', + name: 'Metrics', + unit: '', + dataIngested: '', + pricePerUnit: '', + cost: '', + }, +]; + +const dummyColumns: ColumnsType = [ + { + title: '', + dataIndex: 'name', + key: 'name', + render: renderSkeletonInput, + }, + { + title: 'Unit', + dataIndex: 'unit', + key: 'unit', + render: renderSkeletonInput, + }, + { + title: 'Data Ingested', + dataIndex: 'dataIngested', + key: 'dataIngested', + render: renderSkeletonInput, + }, + { + title: 'Price per Unit', + dataIndex: 'pricePerUnit', + key: 'pricePerUnit', + render: renderSkeletonInput, + }, + { + title: 'Cost (Billing period to date)', + dataIndex: 'cost', + key: 'cost', + render: renderSkeletonInput, + }, +]; + +export const getRemainingDays = (billingEndDate: number): number => { + // Convert Epoch timestamps to Date objects + const startDate = new Date(); // Convert seconds to milliseconds + const endDate = new Date(billingEndDate * 1000); // Convert seconds to milliseconds + + // Calculate the time difference in milliseconds + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const timeDifference = endDate - startDate; + + return Math.ceil(timeDifference / (1000 * 60 * 60 * 24)); +}; + +export const getFormattedDate = (date?: number): string => { + if (!date) { + return new Date().toLocaleDateString(); + } + const trialEndDate = new Date(date * 1000); + + const options = { day: 'numeric', month: 'short', year: 'numeric' }; + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + return trialEndDate.toLocaleDateString(undefined, options); +}; + +export default function BillingContainer(): JSX.Element { + const daysRemainingStr = 'days remaining in your billing period.'; + const [headerText, setHeaderText] = useState(''); + const [billAmount, setBillAmount] = useState(0); + const [totalBillAmount, setTotalBillAmount] = useState(0); + const [activeLicense, setActiveLicense] = useState(null); + const [daysRemaining, setDaysRemaining] = useState(0); + const [isFreeTrial, setIsFreeTrial] = useState(false); + const [data, setData] = useState([]); + const billCurrency = '$'; + + const { isFetching, data: licensesData, error: licenseError } = useLicense(); + + const { user } = useSelector((state) => state.app); + const { notifications } = useNotifications(); + + const handleError = useAxiosError(); + + const { isLoading, data: usageData } = useQuery( + [REACT_QUERY_KEY.GET_BILLING_USAGE, user?.userId], + { + queryFn: () => getUsage(activeLicense?.key || ''), + onError: handleError, + enabled: activeLicense !== null, + }, + ); + + useEffect(() => { + const activeValidLicense = + licensesData?.payload?.licenses?.find( + (license) => license.isCurrent === true, + ) || null; + + setActiveLicense(activeValidLicense); + + if (!isFetching && licensesData?.payload?.onTrial && !licenseError) { + setIsFreeTrial(true); + setBillAmount(0); + setDaysRemaining(getRemainingDays(licensesData?.payload?.trialEnd)); + setHeaderText( + `You are in free trial period. Your free trial will end on ${getFormattedDate( + licensesData?.payload?.trialEnd, + )}`, + ); + } + }, [isFetching, licensesData?.payload, licenseError]); + + const processUsageData = useCallback( + (data: any): void => { + const { + details: { breakdown = [], total, billTotal }, + billingPeriodStart, + billingPeriodEnd, + } = data?.payload || {}; + const formattedUsageData: any[] = []; + + for (let index = 0; index < breakdown.length; index += 1) { + const element = breakdown[index]; + + element?.tiers.forEach( + ( + tier: { quantity: number; unitPrice: number; tierCost: number }, + i: number, + ) => { + formattedUsageData.push({ + key: `${index}${i}`, + name: i === 0 ? element?.type : '', + unit: element?.unit, + dataIngested: tier.quantity, + pricePerUnit: tier.unitPrice, + cost: `$ ${tier.tierCost}`, + }); + }, + ); + } + + setData(formattedUsageData); + setTotalBillAmount(total); + + if (!licensesData?.payload?.onTrial) { + setHeaderText( + `Your current billing period is from ${getFormattedDate( + billingPeriodStart, + )} to ${getFormattedDate(billingPeriodEnd)}`, + ); + setDaysRemaining(getRemainingDays(billingPeriodEnd) - 1); + setBillAmount(billTotal); + } + }, + [licensesData?.payload?.onTrial], + ); + + useEffect(() => { + if (!isLoading && usageData) { + processUsageData(usageData); + } + }, [isLoading, processUsageData, usageData]); + + const columns: ColumnsType = [ + { + title: '', + dataIndex: 'name', + key: 'name', + render: (text): JSX.Element =>
{text}
, + }, + { + title: 'Unit', + dataIndex: 'unit', + key: 'unit', + }, + { + title: 'Data Ingested', + dataIndex: 'dataIngested', + key: 'dataIngested', + }, + { + title: 'Price per Unit', + dataIndex: 'pricePerUnit', + key: 'pricePerUnit', + }, + { + title: 'Cost (Billing period to date)', + dataIndex: 'cost', + key: 'cost', + }, + ]; + + const renderSummary = (): JSX.Element => ( + + + + Total + + +   +   +   + + + ${totalBillAmount} + + + + ); + + const renderTableSkeleton = (): JSX.Element => ( + ( + + )), + }} + /> + ); + + const { mutate: updateCreditCard, isLoading: isLoadingBilling } = useMutation( + updateCreditCardApi, + { + onSuccess: (data) => { + if (data.payload?.redirectURL) { + const newTab = document.createElement('a'); + newTab.href = data.payload.redirectURL; + newTab.target = '_blank'; + newTab.rel = 'noopener noreferrer'; + newTab.click(); + } + }, + onError: () => + notifications.error({ + message: SOMETHING_WENT_WRONG, + }), + }, + ); + + const handleBilling = useCallback(async () => { + updateCreditCard({ + licenseKey: activeLicense?.key || '', + successURL: window.location.href, + cancelURL: window.location.href, + }); + }, [activeLicense?.key, updateCreditCard]); + + return ( +
+ +
+ + {headerText} + + + {licensesData?.payload?.onTrial && + licensesData?.payload?.trialConvertedToSubscription && ( + + We have received your card details, your billing will only start after + the end of your free trial period. + + )} + + + + + + + +
+ + Current bill total + + + + {billCurrency} + {billAmount}   + {isFreeTrial ? Free Trial : ''} + + + + {daysRemaining} {daysRemainingStr} + +
+ +
+ {!isLoading && ( +
+ )} + + {isLoading && renderTableSkeleton()} + + + {isFreeTrial && !licensesData?.payload?.trialConvertedToSubscription && ( +
+ +
+ + + Upgrade now to have uninterrupted access + + + + You will be charged only when trial period ends + + + + + Check out features in paid plans   + + here + + + + + + + + + + )} + + ); +} diff --git a/frontend/src/container/Header/Header.styles.scss b/frontend/src/container/Header/Header.styles.scss new file mode 100644 index 0000000000..82dd9b81ff --- /dev/null +++ b/frontend/src/container/Header/Header.styles.scss @@ -0,0 +1,12 @@ +.trial-expiry-banner { + padding: 8px; + background-color: #f25733; + color: white; + text-align: center; +} + +.upgrade-link { + padding: 0px; + padding-right: 4px; + color: white; +} diff --git a/frontend/src/container/Header/ManageLicense/index.tsx b/frontend/src/container/Header/ManageLicense/index.tsx index 377af48103..fee671f641 100644 --- a/frontend/src/container/Header/ManageLicense/index.tsx +++ b/frontend/src/container/Header/ManageLicense/index.tsx @@ -21,7 +21,7 @@ function ManageLicense({ onToggle }: ManageLicenseProps): JSX.Element { return ; } - const isEnterprise = data?.payload?.some( + const isEnterprise = data?.payload?.licenses?.some( (license) => license.isCurrent && license.planKey === LICENSE_PLAN_KEY.ENTERPRISE_PLAN, ); diff --git a/frontend/src/container/Header/index.tsx b/frontend/src/container/Header/index.tsx index ae98295ada..d2463a5e76 100644 --- a/frontend/src/container/Header/index.tsx +++ b/frontend/src/container/Header/index.tsx @@ -1,3 +1,5 @@ +import './Header.styles.scss'; + import { CaretDownFilled, CaretUpFilled, @@ -6,14 +8,20 @@ import { import { Button, Divider, MenuProps, Space, Typography } from 'antd'; import { Logout } from 'api/utils'; import ROUTES from 'constants/routes'; +import { + getFormattedDate, + getRemainingDays, +} from 'container/BillingContainer/BillingContainer'; import Config from 'container/ConfigDropdown'; import { useIsDarkMode, useThemeMode } from 'hooks/useDarkMode'; import useLicense, { LICENSE_PLAN_STATUS } from 'hooks/useLicense'; +import history from 'lib/history'; import { Dispatch, KeyboardEvent, SetStateAction, useCallback, + useEffect, useMemo, useState, } from 'react'; @@ -37,11 +45,13 @@ import { } from './styles'; function HeaderContainer(): JSX.Element { - const { user, currentVersion } = useSelector( + const { user, role, currentVersion } = useSelector( (state) => state.app, ); const isDarkMode = useIsDarkMode(); const { toggleTheme } = useThemeMode(); + const [showTrialExpiryBanner, setShowTrialExpiryBanner] = useState(false); + const [homeRoute, setHomeRoute] = useState(ROUTES.APPLICATION); const [isUserDropDownOpen, setIsUserDropDownOpen] = useState(false); @@ -97,58 +107,100 @@ function HeaderContainer(): JSX.Element { ); }; - const { data } = useLicense(); + const { data: licenseData, isFetching } = useLicense(); const isLicenseActive = - data?.payload?.find((e) => e.isCurrent)?.status === LICENSE_PLAN_STATUS.VALID; + licenseData?.payload?.licenses?.find((e) => e.isCurrent)?.status === + LICENSE_PLAN_STATUS.VALID; + + useEffect(() => { + if ( + !isFetching && + licenseData?.payload?.onTrial && + !licenseData?.payload?.trialConvertedToSubscription && + getRemainingDays(licenseData?.payload.trialEnd) < 7 + ) { + setShowTrialExpiryBanner(true); + } + + if (!isFetching && licenseData?.payload?.workSpaceBlock) { + setHomeRoute(ROUTES.WORKSPACE_LOCKED); + } + }, [licenseData, isFetching]); + + const handleUpgrade = (): void => { + if (role === 'ADMIN') { + history.push(ROUTES.BILLING); + } + }; return ( -
- - - - SigNoz - - SigNoz - - - - - - {!isLicenseActive && ( - + <> + {showTrialExpiryBanner && ( +
+ You are in free trial period. Your free trial will end on{' '} + {getFormattedDate(licenseData?.payload?.trialEnd)}. + {role === 'ADMIN' ? ( + + Please{' '} + + to continue using SigNoz features. + + ) : ( + 'Please contact your administrator for upgrading to a paid plan.' )} - +
+ )} - +
+ + + + SigNoz + + SigNoz + + + - - - {user?.name[0]} - - {!isUserDropDownOpen ? : } - - - - - -
+ + {!isLicenseActive && ( + + )} + + + + + + + {user?.name[0]} + + {!isUserDropDownOpen ? : } + + + + +
+
+ ); } diff --git a/frontend/src/container/Licenses/ListLicenses.tsx b/frontend/src/container/Licenses/ListLicenses.tsx index d0ca5f0782..02d3abbb65 100644 --- a/frontend/src/container/Licenses/ListLicenses.tsx +++ b/frontend/src/container/Licenses/ListLicenses.tsx @@ -2,7 +2,6 @@ import { ColumnsType } from 'antd/lib/table'; import { ResizeTable } from 'components/ResizeTable'; import { useTranslation } from 'react-i18next'; import { License } from 'types/api/licenses/def'; -import { PayloadProps } from 'types/api/licenses/getAll'; function ListLicenses({ licenses }: ListLicensesProps): JSX.Element { const { t } = useTranslation(['licenses']); @@ -38,7 +37,7 @@ function ListLicenses({ licenses }: ListLicensesProps): JSX.Element { } interface ListLicensesProps { - licenses: PayloadProps; + licenses: License[]; } export default ListLicenses; diff --git a/frontend/src/container/Licenses/index.tsx b/frontend/src/container/Licenses/index.tsx index b4d068d908..351d78a636 100644 --- a/frontend/src/container/Licenses/index.tsx +++ b/frontend/src/container/Licenses/index.tsx @@ -19,7 +19,7 @@ function Licenses(): JSX.Element { } const allValidLicense = - data?.payload?.filter((license) => license.isCurrent) || []; + data?.payload?.licenses?.filter((license) => license.isCurrent) || []; const tabs = [ { diff --git a/frontend/src/container/SideNav/SideNav.tsx b/frontend/src/container/SideNav/SideNav.tsx index 1570e12b70..85ca6295ee 100644 --- a/frontend/src/container/SideNav/SideNav.tsx +++ b/frontend/src/container/SideNav/SideNav.tsx @@ -4,6 +4,7 @@ import getLocalStorageKey from 'api/browser/localstorage/get'; import { IS_SIDEBAR_COLLAPSED } from 'constants/app'; import { FeatureKeys } from 'constants/features'; import ROUTES from 'constants/routes'; +import useLicense, { LICENSE_PLAN_KEY } from 'hooks/useLicense'; import history from 'lib/history'; import { useCallback, useLayoutEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -34,12 +35,21 @@ function SideNav(): JSX.Element { getLocalStorageKey(IS_SIDEBAR_COLLAPSED) === 'true', ); const { + role, currentVersion, latestVersion, isCurrentVersionError, featureResponse, } = useSelector((state) => state.app); + const { data } = useLicense(); + + const isOnBasicPlan = + data?.payload?.licenses?.some( + (license) => + license.isCurrent && license.planKey === LICENSE_PLAN_KEY.BASIC_PLAN, + ) || data?.payload?.licenses === null; + const { hostname } = window.location; const menuItems = useMemo( @@ -50,6 +60,10 @@ function SideNav(): JSX.Element { (feature) => feature.name === FeatureKeys.ONBOARDING, )?.active || false; + if (role !== 'ADMIN' || isOnBasicPlan) { + return item.key !== ROUTES.BILLING; + } + if ( !isOnboardingEnabled || !(hostname && hostname.endsWith('signoz.cloud')) @@ -59,7 +73,7 @@ function SideNav(): JSX.Element { return true; }), - [featureResponse.data, hostname], + [featureResponse.data, isOnBasicPlan, hostname, role], ); const { pathname, search } = useLocation(); diff --git a/frontend/src/container/SideNav/config.ts b/frontend/src/container/SideNav/config.ts index 4e6b628457..efb221e52f 100644 --- a/frontend/src/container/SideNav/config.ts +++ b/frontend/src/container/SideNav/config.ts @@ -46,4 +46,5 @@ export const routeConfig: Record = { [ROUTES.VERSION]: [QueryParams.resourceAttributes], [ROUTES.TRACE_EXPLORER]: [QueryParams.resourceAttributes], [ROUTES.PIPELINES]: [QueryParams.resourceAttributes], + [ROUTES.WORKSPACE_LOCKED]: [QueryParams.resourceAttributes], }; diff --git a/frontend/src/container/SideNav/menuItems.tsx b/frontend/src/container/SideNav/menuItems.tsx index 4121c93ac0..a68cbaf1f4 100644 --- a/frontend/src/container/SideNav/menuItems.tsx +++ b/frontend/src/container/SideNav/menuItems.tsx @@ -5,6 +5,7 @@ import { BugOutlined, DashboardFilled, DeploymentUnitOutlined, + FileDoneOutlined, LineChartOutlined, MenuOutlined, RocketOutlined, @@ -60,6 +61,11 @@ const menuItems: SidebarMenu[] = [ label: 'Usage Explorer', icon: , }, + { + key: ROUTES.BILLING, + label: 'Billing', + icon: , + }, { key: ROUTES.SETTINGS, label: 'Settings', diff --git a/frontend/src/container/TopNav/Breadcrumbs/index.tsx b/frontend/src/container/TopNav/Breadcrumbs/index.tsx index 3c3b88da79..855dd1103d 100644 --- a/frontend/src/container/TopNav/Breadcrumbs/index.tsx +++ b/frontend/src/container/TopNav/Breadcrumbs/index.tsx @@ -24,6 +24,8 @@ const breadcrumbNameMap = { [ROUTES.LOGS_EXPLORER]: 'Logs Explorer', [ROUTES.LIVE_LOGS]: 'Live View', [ROUTES.PIPELINES]: 'Pipelines', + [ROUTES.BILLING]: 'Billing', + [ROUTES.WORKSPACE_LOCKED]: 'Workspace Locked', }; function ShowBreadcrumbs(props: RouteComponentProps): JSX.Element { @@ -50,7 +52,7 @@ function ShowBreadcrumbs(props: RouteComponentProps): JSX.Element { const breadcrumbItems = [ - Home + Home , ].concat(extraBreadcrumbItems); diff --git a/frontend/src/container/TopNav/DateTimeSelection/config.ts b/frontend/src/container/TopNav/DateTimeSelection/config.ts index a085aa9015..d8a438bff8 100644 --- a/frontend/src/container/TopNav/DateTimeSelection/config.ts +++ b/frontend/src/container/TopNav/DateTimeSelection/config.ts @@ -84,6 +84,8 @@ export const routesToSkip = [ ROUTES.EDIT_ALERTS, ROUTES.LIST_ALL_ALERT, ROUTES.PIPELINES, + ROUTES.BILLING, + ROUTES.WORKSPACE_LOCKED, ]; export const routesToDisable = [ROUTES.LOGS_EXPLORER, ROUTES.LIVE_LOGS]; diff --git a/frontend/src/hooks/useLicense/constant.ts b/frontend/src/hooks/useLicense/constant.ts index 03bbb7325c..55f81dac46 100644 --- a/frontend/src/hooks/useLicense/constant.ts +++ b/frontend/src/hooks/useLicense/constant.ts @@ -1,5 +1,6 @@ export const LICENSE_PLAN_KEY = { ENTERPRISE_PLAN: 'ENTERPRISE_PLAN', + BASIC_PLAN: 'BASIC_PLAN ', }; export const LICENSE_PLAN_STATUS = { diff --git a/frontend/src/hooks/useUsage/useUsage.tsx b/frontend/src/hooks/useUsage/useUsage.tsx new file mode 100644 index 0000000000..0abcba5ce1 --- /dev/null +++ b/frontend/src/hooks/useUsage/useUsage.tsx @@ -0,0 +1,25 @@ +import getAll from 'api/licenses/getAll'; +import { REACT_QUERY_KEY } from 'constants/reactQueryKeys'; +import { useQuery, UseQueryResult } from 'react-query'; +import { useSelector } from 'react-redux'; +import { AppState } from 'store/reducers'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { PayloadProps } from 'types/api/licenses/getAll'; +import AppReducer from 'types/reducer/app'; + +const useLicense = (): UseLicense => { + const { user } = useSelector((state) => state.app); + + return useQuery({ + queryFn: getAll, + queryKey: [REACT_QUERY_KEY.GET_ALL_LICENCES, user?.email], + enabled: !!user?.email, + }); +}; + +type UseLicense = UseQueryResult< + SuccessResponse | ErrorResponse, + unknown +>; + +export default useLicense; diff --git a/frontend/src/pages/Billing/BillingPage.styles.scss b/frontend/src/pages/Billing/BillingPage.styles.scss new file mode 100644 index 0000000000..ced1d4d055 --- /dev/null +++ b/frontend/src/pages/Billing/BillingPage.styles.scss @@ -0,0 +1,5 @@ +.billingPageContainer { + display: flex; + width: 100%; + color: #fff; +} diff --git a/frontend/src/pages/Billing/BillingPage.tsx b/frontend/src/pages/Billing/BillingPage.tsx new file mode 100644 index 0000000000..ec2123cd4c --- /dev/null +++ b/frontend/src/pages/Billing/BillingPage.tsx @@ -0,0 +1,13 @@ +import './BillingPage.styles.scss'; + +import BillingContainer from 'container/BillingContainer/BillingContainer'; + +function BillingPage(): JSX.Element { + return ( +
+ +
+ ); +} + +export default BillingPage; diff --git a/frontend/src/pages/Billing/index.tsx b/frontend/src/pages/Billing/index.tsx new file mode 100644 index 0000000000..8dad400fe0 --- /dev/null +++ b/frontend/src/pages/Billing/index.tsx @@ -0,0 +1,3 @@ +import BillingPage from './BillingPage'; + +export default BillingPage; diff --git a/frontend/src/pages/WorkspaceLocked/WorkspaceLocked.styles.scss b/frontend/src/pages/WorkspaceLocked/WorkspaceLocked.styles.scss new file mode 100644 index 0000000000..f80a4925bc --- /dev/null +++ b/frontend/src/pages/WorkspaceLocked/WorkspaceLocked.styles.scss @@ -0,0 +1,19 @@ +.workspace-locked-container { + text-align: center; + padding: 48px; + margin: 48px; +} + +.workpace-locked-details { + width: 50%; + margin: 0 auto; +} + +.update-credit-card-btn { + margin: 24px 0; + border-radius: 5px; +} + +.contact-us { + margin-top: 48px; +} diff --git a/frontend/src/pages/WorkspaceLocked/WorkspaceLocked.tsx b/frontend/src/pages/WorkspaceLocked/WorkspaceLocked.tsx new file mode 100644 index 0000000000..1dfba2694c --- /dev/null +++ b/frontend/src/pages/WorkspaceLocked/WorkspaceLocked.tsx @@ -0,0 +1,97 @@ +/* eslint-disable react/no-unescaped-entities */ +import './WorkspaceLocked.styles.scss'; + +import { CreditCardOutlined, LockOutlined } from '@ant-design/icons'; +import { Button, Card, Typography } from 'antd'; +import updateCreditCardApi from 'api/billing/checkout'; +import { SOMETHING_WENT_WRONG } from 'constants/api'; +import { getFormattedDate } from 'container/BillingContainer/BillingContainer'; +import useLicense from 'hooks/useLicense'; +import { useNotifications } from 'hooks/useNotifications'; +import { useCallback, useEffect, useState } from 'react'; +import { useMutation } from 'react-query'; +import { useSelector } from 'react-redux'; +import { AppState } from 'store/reducers'; +import { License } from 'types/api/licenses/def'; +import AppReducer from 'types/reducer/app'; + +export default function WorkspaceBlocked(): JSX.Element { + const { role } = useSelector((state) => state.app); + const isAdmin = role === 'ADMIN'; + const [activeLicense, setActiveLicense] = useState(null); + + const { notifications } = useNotifications(); + + const { isFetching, data: licensesData } = useLicense(); + + useEffect(() => { + const activeValidLicense = + licensesData?.payload?.licenses?.find( + (license) => license.isCurrent === true, + ) || null; + + setActiveLicense(activeValidLicense); + }, [isFetching, licensesData]); + + const { mutate: updateCreditCard, isLoading } = useMutation( + updateCreditCardApi, + { + onSuccess: (data) => { + if (data.payload?.redirectURL) { + const newTab = document.createElement('a'); + newTab.href = data.payload.redirectURL; + newTab.target = '_blank'; + newTab.rel = 'noopener noreferrer'; + newTab.click(); + } + }, + onError: () => + notifications.error({ + message: SOMETHING_WENT_WRONG, + }), + }, + ); + + const handleUpdateCreditCard = useCallback(async () => { + updateCreditCard({ + licenseKey: activeLicense?.key || '', + successURL: window.location.origin, + cancelURL: window.location.origin, + }); + }, [activeLicense?.key, updateCreditCard]); + + return ( + + + Workspace Locked + + + You have been locked out of your workspace because your trial ended without + an upgrade to a paid plan. Your data will continue to be ingested till{' '} + {getFormattedDate(licensesData?.payload?.gracePeriodEnd)} , at which point + we will drop all the ingested data and terminate the account. + {!isAdmin && 'Please contact your administrator for further help'} + + + {isAdmin && ( + + )} + +
+ Got Questions? + + Contact Us + +
+
+ ); +} diff --git a/frontend/src/pages/WorkspaceLocked/index.tsx b/frontend/src/pages/WorkspaceLocked/index.tsx new file mode 100644 index 0000000000..557461a23a --- /dev/null +++ b/frontend/src/pages/WorkspaceLocked/index.tsx @@ -0,0 +1,3 @@ +import WorkspaceLocked from './WorkspaceLocked'; + +export default WorkspaceLocked; diff --git a/frontend/src/types/api/billing/checkout.ts b/frontend/src/types/api/billing/checkout.ts new file mode 100644 index 0000000000..b299b3ef84 --- /dev/null +++ b/frontend/src/types/api/billing/checkout.ts @@ -0,0 +1,9 @@ +export interface CheckoutSuccessPayloadProps { + redirectURL: string; +} + +export interface CheckoutRequestPayloadProps { + licenseKey: string; + successURL: string; + cancelURL: string; +} diff --git a/frontend/src/types/api/licenses/getAll.ts b/frontend/src/types/api/licenses/getAll.ts index 48a4394f43..95ee48aca5 100644 --- a/frontend/src/types/api/licenses/getAll.ts +++ b/frontend/src/types/api/licenses/getAll.ts @@ -1,3 +1,11 @@ import { License } from './def'; -export type PayloadProps = License[]; +export type PayloadProps = { + trialStart: number; + trialEnd: number; + onTrial: boolean; + workSpaceBlock: boolean; + trialConvertedToSubscription: boolean; + gracePeriodEnd: number; + licenses: License[]; +}; diff --git a/frontend/src/utils/permission/index.ts b/frontend/src/utils/permission/index.ts index 7b9b82bae7..1ca3064720 100644 --- a/frontend/src/utils/permission/index.ts +++ b/frontend/src/utils/permission/index.ts @@ -64,7 +64,6 @@ export const routePermission: Record = { SERVICE_METRICS: ['ADMIN', 'EDITOR', 'VIEWER'], SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER'], SIGN_UP: ['ADMIN', 'EDITOR', 'VIEWER'], - SOMETHING_WENT_WRONG: ['ADMIN', 'EDITOR', 'VIEWER'], TRACES_EXPLORER: ['ADMIN', 'EDITOR', 'VIEWER'], TRACE: ['ADMIN', 'EDITOR', 'VIEWER'], TRACE_DETAIL: ['ADMIN', 'EDITOR', 'VIEWER'], @@ -80,4 +79,7 @@ export const routePermission: Record = { TRACE_EXPLORER: ['ADMIN', 'EDITOR', 'VIEWER'], PIPELINES: ['ADMIN', 'EDITOR', 'VIEWER'], GET_STARTED: ['ADMIN', 'EDITOR', 'VIEWER'], + WORKSPACE_LOCKED: ['ADMIN', 'EDITOR', 'VIEWER'], + BILLING: ['ADMIN', 'EDITOR', 'VIEWER'], + SOMETHING_WENT_WRONG: ['ADMIN', 'EDITOR', 'VIEWER'], };