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 0000000000..f803724138 Binary files /dev/null and b/frontend/public/Images/notFound404.png differ 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'], };