fe
This commit is contained in:
1 parent
6bca0ed00b
commit
7b207f2ccb
15 files changed
+204
-15
No files matched your search
@@ -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<BaseBoxProps> = ({ children, sx }) => {
|
||||
export const BaseBox = ({ sx, ...rest }: BoxProps) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Box
|
||||
{...rest}
|
||||
sx={{
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
border: 1,
|
||||
@@ -16,8 +14,6 @@ export const BaseBox: React.FC<BaseBoxProps> = ({ children, sx }) => {
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
<BaseBox
|
||||
padding={theme.spacing(8)}
|
||||
sx={
|
||||
selected
|
||||
? {
|
||||
border: 2,
|
||||
borderColor: theme.palette.accent.main,
|
||||
}
|
||||
: { cursor: "pointer" }
|
||||
}
|
||||
onClick={selected ? undefined : () => onClick?.(plan.plan)}
|
||||
>
|
||||
<Stack spacing={theme.spacing(2)}>
|
||||
<Typography variant="h6" textTransform="capitalize">
|
||||
{plan.plan}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Monitors: {plan.monitorsMax}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Status pages: {plan.statusPagesMax}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Notification channels: {plan.notificationChannelsMax}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Teams: {plan.teamsMax}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Min interval: {Math.round(plan.checksIntervalMsMin / 1000)}s
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Retention: {plan.retentionDays} days
|
||||
</Typography>
|
||||
</Stack>
|
||||
</BaseBox>
|
||||
);
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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 = () => {
|
||||
<Typography>Invite</Typography>
|
||||
</MenuItem>
|
||||
)}
|
||||
{hasBilling && (
|
||||
{hasBilling && IS_SAAS && (
|
||||
<MenuItem onClick={() => handleClick("billing")}>
|
||||
<Typography>Billing</Typography>
|
||||
</MenuItem>
|
||||
|
||||
@@ -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<ApiResponse<Entitlements[]>>(
|
||||
"/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 (
|
||||
<BasePage>
|
||||
<InfoBox
|
||||
title="Choose a plan!"
|
||||
description="Choose a plan that fits your needs and start enjoying our services."
|
||||
/>
|
||||
<Grid
|
||||
container
|
||||
spacing={theme.spacing(10)}
|
||||
columns={{ xs: 1, sm: 2, md: 4 }}
|
||||
>
|
||||
{plans.map((plan) => (
|
||||
<Grid key={plan.plan} size={1}>
|
||||
<PlanCard
|
||||
plan={plan}
|
||||
currentPlan={selectedPlan}
|
||||
onClick={handlePlanClick}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</BasePage>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<void>;
|
||||
subscribePlan: (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Plans, type Entitlements, type PlanKey } from "@/types/entitlements.js";
|
||||
|
||||
export interface IBillingService {
|
||||
listPlans(): Promise<Entitlements[]>;
|
||||
}
|
||||
|
||||
const SERVICE_NAME = "BillingService";
|
||||
|
||||
class BillingService implements IBillingService {
|
||||
public SERVICE_NAME: string;
|
||||
constructor() {
|
||||
this.SERVICE_NAME = SERVICE_NAME;
|
||||
}
|
||||
async listPlans(): Promise<Entitlements[]> {
|
||||
const order: PlanKey[] = ["free", "pro", "business", "enterprise"];
|
||||
return order.map((key) => Plans[key]);
|
||||
}
|
||||
}
|
||||
|
||||
export default BillingService;
|
||||
Reference in new issue
Block a user