diff --git a/client/src/components/design-elements/BaseBox.tsx b/client/src/components/design-elements/BaseBox.tsx index 5f303b025..a072bcdf6 100644 --- a/client/src/components/design-elements/BaseBox.tsx +++ b/client/src/components/design-elements/BaseBox.tsx @@ -1,13 +1,11 @@ -import Box from "@mui/material/Box"; +import Box, { type BoxProps } from "@mui/material/Box"; import { useTheme } from "@mui/material/styles"; -import type { SxProps } from "@mui/material/styles"; -type BaseBoxProps = React.PropsWithChildren<{ sx?: SxProps }>; - -export const BaseBox: React.FC = ({ children, sx }) => { +export const BaseBox = ({ sx, ...rest }: BoxProps) => { const theme = useTheme(); return ( = ({ children, sx }) => { borderRadius: theme.shape.borderRadius, ...sx, }} - > - {children} - + /> ); }; diff --git a/client/src/components/design-elements/PlanCard.tsx b/client/src/components/design-elements/PlanCard.tsx new file mode 100644 index 000000000..52f2427c0 --- /dev/null +++ b/client/src/components/design-elements/PlanCard.tsx @@ -0,0 +1,57 @@ +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import { BaseBox } from "@/components/design-elements"; + +import { useTheme } from "@mui/material/styles"; +import type { Entitlements, PlanKey } from "@/types/entitlements"; + +export const PlanCard = ({ + plan, + currentPlan, + onClick, +}: { + plan: Entitlements; + currentPlan: PlanKey | null; + onClick?: (planKey: PlanKey) => void; +}) => { + const selected = plan.plan === currentPlan; + const theme = useTheme(); + return ( + onClick?.(plan.plan)} + > + + + {plan.plan} + + + Monitors: {plan.monitorsMax} + + + Status pages: {plan.statusPagesMax} + + + Notification channels: {plan.notificationChannelsMax} + + + Teams: {plan.teamsMax} + + + Min interval: {Math.round(plan.checksIntervalMsMin / 1000)}s + + + Retention: {plan.retentionDays} days + + + + ); +}; diff --git a/client/src/components/design-elements/index.tsx b/client/src/components/design-elements/index.tsx index f6e2ba212..66bbf35cd 100644 --- a/client/src/components/design-elements/index.tsx +++ b/client/src/components/design-elements/index.tsx @@ -29,3 +29,4 @@ export { Tabs } from "./Tabs"; export { Breadcrumb } from "./Breadcrumb"; export { InfoBox } from "./InfoBox"; export { Tooltip, TooltipWithInfo } from "./Tooltip"; +export { PlanCard } from "./PlanCard"; diff --git a/client/src/components/inputs/SwitchSettings.tsx b/client/src/components/inputs/SwitchSettings.tsx index 8d2dc5d4e..cce5e7159 100644 --- a/client/src/components/inputs/SwitchSettings.tsx +++ b/client/src/components/inputs/SwitchSettings.tsx @@ -9,6 +9,8 @@ import { useAppSelector } from "@/hooks/AppHooks"; import { useState } from "react"; import { useTheme } from "@mui/material/styles"; import { Tooltip } from "@/components/design-elements"; +const DEPLOYMENT_MODE = import.meta.env.VITE_APP_DEPLOYMENT_MODE; +const IS_SAAS = DEPLOYMENT_MODE === "saas"; export const SettingsSwitch = () => { const navigate = useNavigate(); @@ -105,7 +107,7 @@ export const SettingsSwitch = () => { Invite )} - {hasBilling && ( + {hasBilling && IS_SAAS && ( handleClick("billing")}> Billing diff --git a/client/src/pages/billing/Billing.tsx b/client/src/pages/billing/Billing.tsx index 364ca4760..8035f2d10 100644 --- a/client/src/pages/billing/Billing.tsx +++ b/client/src/pages/billing/Billing.tsx @@ -1,17 +1,53 @@ -import { BasePage, InfoBox } from "@/components/design-elements"; +import { BasePage, InfoBox, PlanCard } from "@/components/design-elements"; +import Grid from "@mui/material/Grid"; +import { useTheme } from "@mui/material/styles"; import { useAppSelector } from "@/hooks/AppHooks"; +import { useGet, usePost } from "@/hooks/UseApi"; +import type { ApiResponse } from "@/hooks/UseApi"; +import type { Entitlements, PlanKey } from "@/types/entitlements"; const BillingPage = () => { const { user } = useAppSelector((state) => state.auth); + const { response } = useGet>( + "/billing/plans", + {}, + {} + ); + const { post, loading: isPosting } = usePost(); + const plans = response?.data ?? []; + const theme = useTheme(); + const selectedPlan = user?.entitlements?.plan || null; + + const handlePlanClick = async (planKey: PlanKey) => { + const result = await post("/billing/subscribe", { plan: planKey }); + const redirectUrl = result?.data?.redirectUrl; + if (redirectUrl) { + window.location.href = redirectUrl; + } + }; - console.log(user); return ( + + {plans.map((plan) => ( + + + + ))} + ); }; diff --git a/client/src/types/entitlements.ts b/client/src/types/entitlements.ts index 5241e47f2..e90ee4b9c 100644 --- a/client/src/types/entitlements.ts +++ b/client/src/types/entitlements.ts @@ -1,7 +1,7 @@ -export type EntitlemenstKey = keyof Entitlements; +export type PlanKey = "free" | "pro" | "business" | "enterprise" | "unlimited"; export interface Entitlements { - plan: string; + plan: PlanKey; monitorsMax: number; notificationChannelsMax: number; statusPagesMax: number; diff --git a/server/src/controllers/BillingController.ts b/server/src/controllers/BillingController.ts new file mode 100644 index 000000000..38a20a8fb --- /dev/null +++ b/server/src/controllers/BillingController.ts @@ -0,0 +1,42 @@ +import type { Request, Response, NextFunction } from "express"; +import BillingService, { + type IBillingService, +} from "@/services/system/BillingService.js"; + +export interface IBillingController { + listPlans: (req: Request, res: Response, next: NextFunction) => Promise; + subscribePlan: ( + req: Request, + res: Response, + next: NextFunction + ) => Promise; +} + +class BillingController implements IBillingController { + private billingService: IBillingService; + constructor(billingService: IBillingService) { + this.billingService = billingService; + } + + listPlans = async (_req: Request, res: Response, next: NextFunction) => { + try { + const plans = await this.billingService.listPlans(); + res.json({ message: "OK", data: plans }); + } catch (error) { + next(error); + } + }; + + subscribePlan = async (req: Request, res: Response, next: NextFunction) => { + try { + res.json({ + message: "OK", + data: { redirectUrl: "https://www.google.com" }, + }); + } catch (error) { + next(error); + } + }; +} + +export default BillingController; diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts index 8aa36299a..d7e6da080 100644 --- a/server/src/controllers/index.ts +++ b/server/src/controllers/index.ts @@ -14,4 +14,5 @@ export { default as RecoveryController } from "./RecoveryController.js"; export { default as ProfileController } from "./ProfileController.js"; export { default as IncidentsController } from "./IncidentsController.js"; export { default as SettingsController } from "./SettingsController.js"; +export { default as BillingController } from "./BillingController.js"; export { default as StripeController } from "./StripeController.js"; diff --git a/server/src/init/controllers.ts b/server/src/init/controllers.ts index 020847bf0..b007f2833 100644 --- a/server/src/init/controllers.ts +++ b/server/src/init/controllers.ts @@ -16,6 +16,7 @@ import { IncidentsController, SettingsController, StripeController, + BillingController, } from "@/controllers/index.js"; export const initControllers = (services: any) => { @@ -76,6 +77,9 @@ export const initControllers = (services: any) => { ); controllers.stripeController = new StripeController(); + controllers.billingController = new BillingController( + services.billingService + ); return controllers; }; diff --git a/server/src/init/routes.ts b/server/src/init/routes.ts index a76f48340..5b419c6a0 100644 --- a/server/src/init/routes.ts +++ b/server/src/init/routes.ts @@ -17,9 +17,9 @@ import { IncidentsRoutes, SettingsRoutes, MeRoutes, + BillingRoutes, } from "@/routes/index.js"; import StripeRoutes from "@/routes/stripe.js"; -import StripeController from "@/controllers/StripeController.js"; import { errorHandler } from "@/middleware/ErrorHandler.js"; export const initRoutes = (controllers: any, app: Express) => { const authRoutes = new AuthRoutes(controllers.authController); @@ -51,6 +51,7 @@ export const initRoutes = (controllers: any, app: Express) => { const settingsRoutes = new SettingsRoutes(controllers.settingsController); const meRoutes = new MeRoutes(); const stripeRoutes = new StripeRoutes(controllers.stripeController); + const billingRoutes = new BillingRoutes(controllers.billingController); app.use("/api/v1/auth", authRoutes.getRouter()); app.use("/api/v1/invite", inviteRoutes.getRouter()); @@ -72,7 +73,7 @@ export const initRoutes = (controllers: any, app: Express) => { app.use("/api/v1/incidents", incidentsRoutes.getRouter()); app.use("/api/v1/settings", settingsRoutes.getRouter()); app.use("/api/v1/me", meRoutes.getRouter()); - // Stripe webhook uses raw body; mounted separately in index.ts app.use("/api/v1/stripe", stripeRoutes.getRouter()); + app.use("/api/v1/billing", billingRoutes.getRouter()); app.use(errorHandler); }; diff --git a/server/src/init/services.ts b/server/src/init/services.ts index a2988f03f..24127cc51 100644 --- a/server/src/init/services.ts +++ b/server/src/init/services.ts @@ -28,6 +28,7 @@ import { RecoveryService, IncidentService, SettingsService, + BillingService, } from "@/services/index.js"; export const initServices = async () => { @@ -43,6 +44,7 @@ export const initServices = async () => { const networkService = new NetworkService(got); const statusService = new StatusService(); const settingsService = new SettingsService(); + const billingService = new BillingService(); const emailService = new EmailService(userService, settingsService); settingsService.setEmailService(emailService); const notificationService = new NotificationService( @@ -96,6 +98,7 @@ export const initServices = async () => { recoveryService, incidentService, settingsService, + billingService, }; Object.values(services).forEach((service) => { diff --git a/server/src/routes/billing.ts b/server/src/routes/billing.ts new file mode 100644 index 000000000..cd12fad87 --- /dev/null +++ b/server/src/routes/billing.ts @@ -0,0 +1,24 @@ +import { Router } from "express"; +import BillingController from "@/controllers/BillingController.js"; +import { verifyToken } from "@/middleware/VerifyToken.js"; + +class BillingRoutes { + private router; + private controller; + constructor(controller: BillingController) { + this.router = Router(); + this.controller = controller; + this.initRoutes(); + } + + initRoutes = () => { + this.router.get("/plans", verifyToken, this.controller.listPlans); + this.router.post("/subscribe", verifyToken, this.controller.subscribePlan); + }; + + getRouter() { + return this.router; + } +} + +export default BillingRoutes; diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts index 50f944c3f..99259f502 100644 --- a/server/src/routes/index.ts +++ b/server/src/routes/index.ts @@ -15,3 +15,4 @@ export { default as ProfileRoutes } from "./profile.js"; export { default as IncidentsRoutes } from "./incidents.js"; export { default as SettingsRoutes } from "./settings.js"; export { default as MeRoutes } from "./me.js"; +export { default as BillingRoutes } from "./billing.js"; diff --git a/server/src/services/index.ts b/server/src/services/index.ts index b6edb5cbe..e3f1dacad 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -23,5 +23,6 @@ export { default as JobGenerator } from "@/services/infrastructure/JobGenerator. export { default as ServiceRegistry } from "@/services/system/ServiceRegistry.js"; export { default as SettingsService } from "@/services/system/SettingsService.js"; +export { default as BillingService } from "@/services/system/BillingService.js"; export * from "@/services/infrastructure/NotificationServices/index.js"; diff --git a/server/src/services/system/BillingService.ts b/server/src/services/system/BillingService.ts new file mode 100644 index 000000000..617aa3e3e --- /dev/null +++ b/server/src/services/system/BillingService.ts @@ -0,0 +1,20 @@ +import { Plans, type Entitlements, type PlanKey } from "@/types/entitlements.js"; + +export interface IBillingService { + listPlans(): Promise; +} + +const SERVICE_NAME = "BillingService"; + +class BillingService implements IBillingService { + public SERVICE_NAME: string; + constructor() { + this.SERVICE_NAME = SERVICE_NAME; + } + async listPlans(): Promise { + const order: PlanKey[] = ["free", "pro", "business", "enterprise"]; + return order.map((key) => Plans[key]); + } +} + +export default BillingService;