This commit is contained in:
Alex Holliday committed 2025-12-01 00:35:53 +00:00
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>
+38 -2
View File
@@ -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>
);
};
+2 -2
View File
@@ -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;
+1
View File
@@ -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";
+4
View File
@@ -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;
};
+3 -2
View File
@@ -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);
};
+3
View File
@@ -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) => {
+24
View File
@@ -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;
+1
View File
@@ -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";
+1
View File
@@ -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;