Openstatus www.openstatus.dev
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: new pricing plans (#560)

* wip:

* fix: missing route in middleware

* chore: add mobile support

* chore: add navigation links

* wip:

* chore: pricing table in billing settings

* ๐Ÿš€ add pricing

* ๐Ÿš€ add pricing

* wip:

* ๐Ÿš€ add pricing

* ๐Ÿš€ add pricing

---------

Co-authored-by: Thibault Le Ouay <thibaultleouay@gmail.Com>

authored by

Maximilian Kaske
Thibault Le Ouay
and committed by
GitHub
bceb29a5 310ff3b4

+860 -314
-1
apps/checker/request/request.go
··· 11 11 Key string `json:"key"` 12 12 Value string `json:"value"` 13 13 } `json:"headers,omitempty"` 14 - PagesIDs []string `json:"pagesIds"` 15 14 Status string `json:"status"` 16 15 }
+1 -3
apps/web/.env.example
··· 40 40 # STRIPE 41 41 NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY= 42 42 STRIPE_SECRET_KEY= 43 - STRIPE_PRO_MONTHLY_PRICE_ID= 44 - STRIPE_PRO_PRODUCT_ID= 45 43 STRIPE_WEBHOOK_SECRET_KEY= 46 44 47 45 # Custom Domains ··· 73 71 GCP_PRIVATE_KEY= 74 72 CRON_SECRET= 75 73 76 - EXTERNAL_API_URL= 74 + EXTERNAL_API_URL=
+16
apps/web/src/app/api/checker/cron/30s/route.ts
··· 1 + import type { NextRequest } from "next/server"; 2 + import { NextResponse } from "next/server"; 3 + 4 + import { cron, isAuthorizedDomain } from "../_cron"; 5 + 6 + // export const runtime = "edge"; 7 + // export const preferredRegion = ["auto"]; 8 + export const dynamic = "force-dynamic"; 9 + export const maxDuration = 300; 10 + 11 + export async function GET(req: NextRequest) { 12 + if (isAuthorizedDomain(req.url)) { 13 + await cron({ periodicity: "30s", req }); 14 + } 15 + return NextResponse.json({ success: true }); 16 + }
+72 -47
apps/web/src/app/api/checker/cron/_cron.ts
··· 6 6 7 7 import { createTRPCContext } from "@openstatus/api"; 8 8 import { edgeRouter } from "@openstatus/api/src/edge"; 9 + import type { MonitorStatus } from "@openstatus/db/src/schema"; 9 10 import { selectMonitorSchema } from "@openstatus/db/src/schema"; 10 11 11 12 import { env } from "@/env"; ··· 55 56 const allResult = []; 56 57 57 58 for (const row of monitors) { 58 - // TODO: remove - this is not used anymore | remember to update the `type Payload` 59 - const allPages = await caller.monitor.getAllPagesForMonitor({ 60 - monitorId: row.id, 61 - }); 62 59 const selectedRegions = row.regions.length > 1 ? row.regions : ["auto"]; 63 60 64 61 const monitorStatus = await caller.monitor.getMonitorStatusByMonitorId({ ··· 66 63 }); 67 64 68 65 for (const region of selectedRegions) { 69 - const payload: z.infer<typeof payloadSchema> = { 70 - workspaceId: String(row.workspaceId), 71 - monitorId: String(row.id), 72 - url: row.url, 73 - method: row.method || "GET", 74 - cronTimestamp: timestamp, 75 - body: row.body, 76 - headers: row.headers, 77 - pageIds: allPages.map((p) => String(p.pageId)), 78 - status: 79 - monitorStatus.find(({ region }) => region === region)?.status || 80 - "active", 81 - }; 82 - 83 - // const task: google.cloud.tasks.v2beta3.ITask = { 84 - // httpRequest: { 85 - // headers: { 86 - // "Content-Type": "application/json", // Set content type to ensure compatibility your application's request parsing 87 - // ...(region !== "auto" && { "fly-prefer-region": region }), // Specify the region you want the request to be sent to 88 - // Authorization: `Basic ${env.CRON_SECRET}`, 89 - // }, 90 - // httpMethod: "POST", 91 - // url: "https://api.openstatus.dev/checkerV2", 92 - // body: Buffer.from(JSON.stringify(payload)).toString("base64"), 93 - // }, 94 - // }; 95 - const newTask: google.cloud.tasks.v2beta3.ITask = { 96 - httpRequest: { 97 - headers: { 98 - "Content-Type": "application/json", // Set content type to ensure compatibility your application's request parsing 99 - ...(region !== "auto" && { "fly-prefer-region": region }), // Specify the region you want the request to be sent to 100 - Authorization: `Basic ${env.CRON_SECRET}`, 101 - }, 102 - httpMethod: "POST", 103 - url: "https://openstatus-checker.fly.dev/checker", 104 - body: Buffer.from(JSON.stringify(payload)).toString("base64"), 105 - }, 106 - }; 107 - 108 - // const request = { parent: parent, task: task }; 109 - // const [response] = await client.createTask(request); 110 - const request = { parent: parent, task: newTask }; 111 - const [response] = await client.createTask(request); 66 + const status = 67 + monitorStatus.find(({ region }) => region === region)?.status || 68 + "active"; 69 + const response = await createCronTask({ 70 + row, 71 + timestamp, 72 + client, 73 + parent, 74 + status, 75 + region, 76 + }); 112 77 allResult.push(response); 78 + if (periodicity === "30s") { 79 + // we schedule another task in 30s 80 + const scheduledAt = timestamp / 1000 + 30; 81 + const response = await createCronTask({ 82 + row, 83 + timestamp: scheduledAt, 84 + client, 85 + parent, 86 + status, 87 + region, 88 + }); 89 + allResult.push(response); 90 + } 113 91 } 114 92 } 115 93 await Promise.all(allResult); 116 94 console.log(`End cron for ${periodicity} with ${allResult.length} jobs`); 117 95 }; 96 + 97 + const createCronTask = async ({ 98 + row, 99 + timestamp, 100 + client, 101 + parent, 102 + status, 103 + region, 104 + }: { 105 + row: z.infer<typeof selectMonitorSchema>; 106 + timestamp: number; 107 + client: CloudTasksClient; 108 + parent: string; 109 + status: MonitorStatus; 110 + region: string; 111 + }) => { 112 + const payload: z.infer<typeof payloadSchema> = { 113 + workspaceId: String(row.workspaceId), 114 + monitorId: String(row.id), 115 + url: row.url, 116 + method: row.method || "GET", 117 + cronTimestamp: timestamp, 118 + body: row.body, 119 + headers: row.headers, 120 + status: status, 121 + }; 122 + 123 + const newTask: google.cloud.tasks.v2beta3.ITask = { 124 + httpRequest: { 125 + headers: { 126 + "Content-Type": "application/json", // Set content type to ensure compatibility your application's request parsing 127 + "fly-prefer-region": region, // Specify the region you want the request to be sent to 128 + Authorization: `Basic ${env.CRON_SECRET}`, 129 + }, 130 + httpMethod: "POST", 131 + url: "https://openstatus-checker.fly.dev/checker", 132 + body: Buffer.from(JSON.stringify(payload)).toString("base64"), 133 + }, 134 + scheduleTime: { 135 + seconds: timestamp, 136 + }, 137 + }; 138 + 139 + const request = { parent: parent, task: newTask }; 140 + const [response] = await client.createTask(request); 141 + return response; 142 + };
-1
apps/web/src/app/api/checker/schema.ts
··· 10 10 headers: z.array(z.object({ key: z.string(), value: z.string() })).optional(), 11 11 url: z.string(), 12 12 cronTimestamp: z.number(), 13 - pageIds: z.array(z.string()), 14 13 status: z.enum(monitorStatus), 15 14 }); 16 15
+1 -1
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/[id]/layout.tsx
··· 29 29 segment: "overview", 30 30 }, 31 31 { 32 - label: "Data Table", 32 + label: "Requests Log", 33 33 href: `/app/${params.workspaceSlug}/monitors/${id}/data`, 34 34 segment: "data", 35 35 },
+84
apps/web/src/app/app/[workspaceSlug]/(dashboard)/settings/billing/_components/change-plan-button.tsx
··· 1 + "use client"; 2 + 3 + import { useState, useTransition } from "react"; 4 + import { useRouter } from "next/navigation"; 5 + import { TRPCClientError } from "@trpc/client"; 6 + 7 + import { workspacePlans } from "@openstatus/db/src/schema"; 8 + import type { Workspace, WorkspacePlan } from "@openstatus/db/src/schema"; 9 + import { 10 + Button, 11 + Dialog, 12 + DialogContent, 13 + DialogDescription, 14 + DialogFooter, 15 + DialogHeader, 16 + DialogTitle, 17 + DialogTrigger, 18 + toast, 19 + } from "@openstatus/ui"; 20 + 21 + import { LoadingAnimation } from "@/components/loading-animation"; 22 + import { api } from "@/trpc/client"; 23 + 24 + export function ChangePlanButton({ workspace }: { workspace: Workspace }) { 25 + const [open, setOpen] = useState(false); 26 + const [isPending, startTransition] = useTransition(); 27 + const router = useRouter(); 28 + 29 + function onChange(plan: WorkspacePlan) { 30 + startTransition(async () => { 31 + try { 32 + await api.workspace.changePlan.mutate({ plan }); 33 + } catch (e) { 34 + if (e instanceof TRPCClientError) { 35 + toast({ 36 + description: e.message, 37 + variant: "destructive", 38 + }); 39 + } 40 + } finally { 41 + setOpen(false); 42 + router.refresh(); 43 + } 44 + }); 45 + } 46 + 47 + return ( 48 + <Dialog open={open} onOpenChange={setOpen}> 49 + <DialogTrigger asChild> 50 + <Button variant={workspace.plan === "free" ? "default" : "outline"}> 51 + Change Plan 52 + </Button> 53 + </DialogTrigger> 54 + <DialogContent> 55 + <DialogHeader> 56 + <DialogTitle>Change plan</DialogTitle> 57 + <DialogDescription> 58 + You are currently on the{" "} 59 + <span className="font-bold">{workspace.plan}</span> plan. 60 + </DialogDescription> 61 + </DialogHeader> 62 + <DialogFooter className="grid w-full grid-cols-4 gap-3"> 63 + {workspacePlans.map((plan) => { 64 + const isActive = plan === workspace.plan; 65 + return ( 66 + <Button 67 + key={plan} 68 + onClick={() => onChange(plan)} 69 + disabled={isPending || isActive} 70 + variant="outline" 71 + > 72 + {isPending && !isActive ? ( 73 + <LoadingAnimation variant="inverse" /> 74 + ) : ( 75 + plan 76 + )} 77 + </Button> 78 + ); 79 + })} 80 + </DialogFooter> 81 + </DialogContent> 82 + </Dialog> 83 + ); 84 + }
+30 -37
apps/web/src/app/app/[workspaceSlug]/(dashboard)/settings/billing/_components/plan.tsx
··· 3 3 import { useTransition } from "react"; 4 4 import { useRouter } from "next/navigation"; 5 5 6 - import type { Workspace } from "@openstatus/db/src/schema"; 6 + import type { Workspace, WorkspacePlan } from "@openstatus/db/src/schema"; 7 + import { Button } from "@openstatus/ui"; 7 8 8 - import { Shell } from "@/components/dashboard/shell"; 9 - import { Plan } from "@/components/marketing/plans"; 10 - import type { PlanProps } from "@/config/plans"; 11 - import { plansConfig } from "@/config/plans"; 9 + import { LoadingAnimation } from "@/components/loading-animation"; 10 + import { PricingTable } from "@/components/marketing/pricing/pricing-table"; 12 11 import { getStripe } from "@/lib/stripe/client"; 13 12 import { api } from "@/trpc/client"; 14 13 ··· 17 16 const [isPending, startTransition] = useTransition(); 18 17 const [isPortalPending, startPortalTransition] = useTransition(); 19 18 20 - const getCheckoutSession = () => { 19 + const getCheckoutSession = (plan: WorkspacePlan) => { 21 20 startTransition(async () => { 22 21 const result = await api.stripeRouter.getCheckoutSession.mutate({ 23 22 workspaceSlug: workspace.slug, 23 + plan, 24 24 }); 25 25 if (!result) return; 26 26 27 27 const stripe = await getStripe(); 28 - stripe?.redirectToCheckout({ sessionId: result.id }); 28 + stripe?.redirectToCheckout({ 29 + sessionId: result.id, 30 + }); 29 31 }); 30 32 }; 31 33 ··· 40 42 }); 41 43 }; 42 44 43 - const plans: Record<"free" | "pro", PlanProps> = { 44 - free: { 45 - ...plansConfig.free, 46 - loading: isPortalPending, 47 - action: { 48 - text: workspace?.plan === "free" ? "Current plan" : "Downgrade", 49 - onClick: async () => { 50 - await getUserCustomerPortal(); 51 - }, 52 - }, 53 - }, 54 - pro: { 55 - ...plansConfig.pro, 56 - loading: isPending, 57 - action: { 58 - text: workspace?.plan === "free" ? "Upgrade" : "Current plan", 59 - onClick: async () => { 60 - await getCheckoutSession(); 61 - }, 62 - }, 63 - }, 64 - }; 65 - 66 45 return ( 67 - <Shell className="mt-4 w-full"> 68 - <div className="grid gap-4 md:grid-cols-2 md:gap-0"> 69 - <Plan 70 - {...plans.free} 71 - className="md:border-border/50 md:border-r md:pr-4" 46 + <div className="grid gap-4"> 47 + <div className="grid gap-6"> 48 + <div> 49 + <Button onClick={getUserCustomerPortal} variant="outline"> 50 + {isPortalPending ? ( 51 + <LoadingAnimation variant="inverse" /> 52 + ) : ( 53 + "Customer Portal" 54 + )} 55 + </Button> 56 + </div> 57 + <PricingTable 58 + currentPlan={workspace.plan} 59 + isLoading={isPending} 60 + events={{ 61 + free: () => getCheckoutSession("free"), 62 + starter: () => getCheckoutSession("starter"), 63 + pro: () => getCheckoutSession("pro"), 64 + team: () => getCheckoutSession("team"), 65 + }} 72 66 /> 73 - <Plan {...plans.pro} className="md:pl-4" /> 74 67 </div> 75 - </Shell> 68 + </div> 76 69 ); 77 70 };
+7 -16
apps/web/src/app/app/[workspaceSlug]/(dashboard)/settings/billing/page.tsx
··· 1 - import { Badge } from "@openstatus/ui"; 2 - 3 1 import { api } from "@/trpc/server"; 4 2 import { CustomerPortalButton } from "./_components/customer-portal-button"; 5 3 import { SettingsPlan } from "./_components/plan"; 6 4 7 5 export default async function BillingPage() { 8 - const data = await api.workspace.getWorkspace.query(); 6 + const workspace = await api.workspace.getWorkspace.query(); 9 7 return ( 10 - <> 11 - <h3 className="text-lg font-medium">Plans</h3> 12 - <div className="text-muted-foreground flex items-center space-x-2 text-sm"> 13 - Your current plan is{" "} 14 - <Badge className="ml-2">{data?.plan || "free"}</Badge> 15 - </div> 16 - {data?.plan === "pro" ? ( 17 - <div> 18 - <CustomerPortalButton workspaceSlug={data.slug} /> 19 - </div> 20 - ) : null} 21 - <SettingsPlan workspace={data} /> 22 - </> 8 + <div className="grid gap-3"> 9 + <h3 className="text-lg font-medium"> 10 + <span className="capitalize">{workspace.plan}</span> plan 11 + </h3> 12 + <SettingsPlan workspace={workspace} /> 13 + </div> 23 14 ); 24 15 }
+2 -12
apps/web/src/app/app/[workspaceSlug]/(dashboard)/settings/team/page.tsx
··· 1 1 import { AlertTriangle } from "lucide-react"; 2 2 3 - import { allPlans } from "@openstatus/plans"; 4 - import { 5 - Alert, 6 - AlertDescription, 7 - AlertTitle, 8 - ButtonWithDisableTooltip, 9 - } from "@openstatus/ui"; 3 + import { Alert, AlertDescription, AlertTitle } from "@openstatus/ui"; 10 4 11 5 import { columns as invitationColumns } from "@/components/data-table/invitation/columns"; 12 6 import { DataTable as InvitationDataTable } from "@/components/data-table/invitation/data-table"; ··· 20 14 const invitations = await api.invitation.getWorkspaceOpenInvitations.query(); 21 15 const users = await api.workspace.getWorkspaceUsers.query(); 22 16 23 - const isLimit = 24 - invitations.length + users.length >= 25 - allPlans[workspace.plan || "free"].limits.members; 26 - 27 17 const isFreePlan = workspace.plan === "free"; 28 18 29 19 return ( ··· 39 29 ) : null} 40 30 {/* TODO: only show if isAdmin */} 41 31 <div className="flex justify-end"> 42 - <InviteButton disabled={isLimit} /> 32 + <InviteButton disabled={isFreePlan} /> 43 33 </div> 44 34 <UserDataTable 45 35 data={users.map(({ role, user }) => ({ role, ...user }))}
+4 -2
apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-pages/[id]/domain/page.tsx
··· 1 1 import { notFound } from "next/navigation"; 2 2 3 + import { allPlans } from "@openstatus/plans"; 4 + 3 5 import { ProFeatureAlert } from "@/components/billing/pro-feature-alert"; 4 6 import { CustomDomainForm } from "@/components/forms/custom-domain-form"; 5 7 import { api } from "@/trpc/server"; ··· 13 15 const page = await api.page.getPageById.query({ id }); 14 16 const workspace = await api.workspace.getWorkspace.query(); 15 17 16 - const isProPlan = workspace?.plan === "pro"; 18 + const isValid = allPlans[workspace.plan].limits["custom-domain"]; 17 19 18 20 if (!page) return notFound(); 19 21 20 - if (!isProPlan) return <ProFeatureAlert feature="Custom domains" />; 22 + if (!isValid) return <ProFeatureAlert feature="Custom domains" />; 21 23 22 24 return ( 23 25 <CustomDomainForm
+4 -3
apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-pages/[id]/subscribers/page.tsx
··· 1 1 import { notFound } from "next/navigation"; 2 2 3 + import { allPlans } from "@openstatus/plans"; 4 + 3 5 import { ProFeatureAlert } from "@/components/billing/pro-feature-alert"; 4 6 import { api } from "@/trpc/server"; 5 7 ··· 12 14 const page = await api.page.getPageById.query({ id }); 13 15 const workspace = await api.workspace.getWorkspace.query(); 14 16 15 - const isProPlan = workspace?.plan === "pro"; 17 + const isValid = allPlans[workspace.plan].limits["status-subscribers"]; 16 18 17 19 if (!page) return notFound(); 18 20 19 - if (!isProPlan) 20 - return <ProFeatureAlert feature={"Status page subscribers"} />; 21 + if (!isValid) return <ProFeatureAlert feature={"Status page subscribers"} />; 21 22 22 23 // TODO: add page-subscribers trpc endpoint first 23 24 return (
-2
apps/web/src/app/page.tsx
··· 4 4 import { Hero } from "@/components/marketing/hero"; 5 5 import { MonitoringCard } from "@/components/marketing/monitor/card"; 6 6 import { Partners } from "@/components/marketing/partners"; 7 - import { Plans } from "@/components/marketing/plans"; 8 7 import { Stats } from "@/components/marketing/stats"; 9 8 import { StatusPageCard } from "@/components/marketing/status-page/card"; 10 9 ··· 20 19 <Stats /> 21 20 <StatusPageCard /> 22 21 <AlertCard /> 23 - <Plans /> 24 22 <FAQs /> 25 23 </div> 26 24 </MarketingLayout>
+37
apps/web/src/app/pricing/page.tsx
··· 1 + import Link from "next/link"; 2 + 3 + import { Shell } from "@/components/dashboard/shell"; 4 + import { MarketingLayout } from "@/components/layout/marketing-layout"; 5 + import { EnterpricePlan } from "@/components/marketing/pricing/enterprice-plan"; 6 + import { PricingWrapper } from "@/components/marketing/pricing/pricing-wrapper"; 7 + 8 + export default function PricingPage() { 9 + return ( 10 + <MarketingLayout> 11 + <div className="grid w-full gap-6"> 12 + <Shell className="grid w-full gap-8"> 13 + <div className="grid gap-3 text-center"> 14 + <h1 className="text-foreground font-cal text-4xl">Pricing</h1> 15 + <p className="text-muted-foreground"> 16 + All plans. Start free today, upgrade later. 17 + </p> 18 + </div> 19 + <PricingWrapper /> 20 + <p className="text-muted-foreground text-sm"> 21 + Learn more about the{" "} 22 + <Link 23 + href="/blog" 24 + className="text-foreground underline underline-offset-4 hover:no-underline" 25 + > 26 + decision behind the plans 27 + </Link> 28 + . 29 + </p> 30 + </Shell> 31 + <Shell> 32 + <EnterpricePlan /> 33 + </Shell> 34 + </div> 35 + </MarketingLayout> 36 + ); 37 + }
+5 -2
apps/web/src/app/status-page/[domain]/layout.tsx
··· 2 2 import Image from "next/image"; 3 3 import { notFound } from "next/navigation"; 4 4 5 - import { Avatar, AvatarFallback, AvatarImage } from "@openstatus/ui"; 5 + import { allPlans } from "@openstatus/plans"; 6 6 7 7 import { 8 8 defaultMetadata, ··· 38 38 }, 39 39 ]; 40 40 41 + const isSubscribersValid = 42 + allPlans[page.workspacePlan].limits["status-subscribers"]; 43 + 41 44 return ( 42 45 <div className="flex min-h-screen w-full flex-col space-y-6 p-4 md:p-8"> 43 46 <header className="mx-auto w-full max-w-xl"> ··· 57 60 </div> 58 61 <Navbar navigation={navigation} /> 59 62 <div className="text-end sm:w-[100px]"> 60 - {page.workspacePlan !== "free" ? ( 63 + {isSubscribersValid ? ( 61 64 <SubscribeButton slug={params.domain} /> 62 65 ) : null} 63 66 </div>
+1
apps/web/src/components/forms/monitor-form.tsx
··· 71 71 import { NotificationForm } from "./notification-form"; 72 72 73 73 const cronJobs = [ 74 + { value: "30s", label: "30 seconds" }, 74 75 { value: "1m", label: "1 minute" }, 75 76 { value: "5m", label: "5 minutes" }, 76 77 { value: "10m", label: "10 minutes" },
+1
apps/web/src/components/layout/marketing-footer.tsx
··· 31 31 <p className="text-foreground font-semibold">Resources</p> 32 32 <FooterLink href="/blog" label="Blog" /> 33 33 <FooterLink href="/changelog" label="Changelog" /> 34 + <FooterLink href="/pricing" label="Pricing" /> 34 35 <FooterLink href="https://docs.openstatus.dev" label="Docs" /> 35 36 <FooterLink href="/oss-friends" label="OSS Friends" /> 36 37 <FooterLink href="/status" label="External Providers Monitoring" />
+3
apps/web/src/components/layout/marketing-header.tsx
··· 32 32 <Link href="/changelog">Changelog</Link> 33 33 </Button> 34 34 <Button variant="link" asChild> 35 + <Link href="/pricing">Pricing</Link> 36 + </Button> 37 + <Button variant="link" asChild> 35 38 <Link href="https://docs.openstatus.dev" target="_blank"> 36 39 Docs 37 40 <ArrowUpRight className="ml-1 h-4 w-4 flex-shrink-0" />
+1
apps/web/src/components/layout/marketing-menu.tsx
··· 18 18 const pages = [ 19 19 { href: "/changelog", label: "Changelog", segment: "changelog" }, 20 20 { href: "/blog", label: "Blog", segment: "blog" }, 21 + { href: "/pricing", label: "Pricing", segment: "pricing" }, 21 22 { href: "https://docs.openstatus.dev", label: "Documentation" }, 22 23 ]; 23 24
-96
apps/web/src/components/marketing/plans.tsx
··· 1 - import Link from "next/link"; 2 - import { Check } from "lucide-react"; 3 - 4 - import { Button } from "@openstatus/ui"; 5 - 6 - import { Shell } from "@/components/dashboard/shell"; 7 - import { LoadingAnimation } from "@/components/loading-animation"; 8 - import type { PlanProps } from "@/config/plans"; 9 - import { plansConfig } from "@/config/plans"; 10 - import { cn } from "@/lib/utils"; 11 - 12 - export function Plans() { 13 - return ( 14 - <Shell className="grid gap-4 md:grid-cols-2 md:gap-0"> 15 - <Plan 16 - {...plansConfig.free} 17 - className="md:border-border/50 md:border-r md:pr-4" 18 - /> 19 - <Plan {...plansConfig.pro} className="md:pl-4" /> 20 - <Plan 21 - {...plansConfig.enterprise} 22 - className="md:border-border/50 col-span-full md:mt-4 md:border-t md:pt-4" 23 - /> 24 - </Shell> 25 - ); 26 - } 27 - 28 - interface Props extends PlanProps { 29 - className?: string; 30 - } 31 - 32 - export function Plan({ 33 - title, 34 - description, 35 - cost, 36 - features, 37 - action, 38 - disabled, 39 - className, 40 - loading, 41 - }: Props) { 42 - return ( 43 - <div 44 - key={title} 45 - className={cn( 46 - "flex w-full flex-col", 47 - disabled && "pointer-events-none opacity-70", 48 - className, 49 - )} 50 - > 51 - <div className="flex-1"> 52 - <div className="flex items-end justify-between gap-4"> 53 - <div> 54 - <p className="font-cal mb-2 text-xl">{title}</p> 55 - <p className="text-muted-foreground">{description}</p> 56 - </div> 57 - <p className="shrink-0"> 58 - {typeof cost === "number" ? ( 59 - <> 60 - <span className="font-cal text-2xl">{cost} โ‚ฌ</span> 61 - <span className="text-muted-foreground font-light">/month</span> 62 - </> 63 - ) : ( 64 - <span className="font-cal text-2xl">{cost}</span> 65 - )} 66 - </p> 67 - </div> 68 - <ul className="border-border/50 grid divide-y py-2"> 69 - {features.map((item) => ( 70 - <li 71 - key={item} 72 - className="text-muted-foreground inline-flex items-center py-2 text-sm" 73 - > 74 - <Check className="mr-2 h-4 w-4 text-green-500" /> 75 - {item} 76 - </li> 77 - ))} 78 - </ul> 79 - </div> 80 - {action ? ( 81 - <div> 82 - {"link" in action ? ( 83 - <Button asChild> 84 - <Link href={action.link}>{action.text}</Link> 85 - </Button> 86 - ) : null} 87 - {"onClick" in action ? ( 88 - <Button onClick={action.onClick} disabled={disabled || loading}> 89 - {loading ? <LoadingAnimation /> : action.text} 90 - </Button> 91 - ) : null} 92 - </div> 93 - ) : null} 94 - </div> 95 - ); 96 - }
+28
apps/web/src/components/marketing/pricing/enterprice-plan.tsx
··· 1 + import { Button } from "@openstatus/ui"; 2 + 3 + export function EnterpricePlan() { 4 + return ( 5 + <div className="flex w-full flex-col gap-3"> 6 + <div className="flex-1"> 7 + <div className="flex items-end justify-between gap-4"> 8 + <div> 9 + <p className="font-cal mb-2 text-xl">Enterprise</p> 10 + <p className="text-muted-foreground"> 11 + Dedicated support and needs for your company. 12 + </p> 13 + </div> 14 + <p className="shrink-0"> 15 + <span className="font-cal text-2xl">Lets talk</span> 16 + </p> 17 + </div> 18 + </div> 19 + <div> 20 + <Button asChild> 21 + <a href="https://cal.com/team/openstatus/30min" target="_blank"> 22 + Schedule call 23 + </a> 24 + </Button> 25 + </div> 26 + </div> 27 + ); 28 + }
+42
apps/web/src/components/marketing/pricing/pricing-plan-radio.tsx
··· 1 + "use client"; 2 + 3 + import { useRouter } from "next/navigation"; 4 + 5 + import { allPlans, plans } from "@openstatus/plans"; 6 + import { Label, RadioGroup, RadioGroupItem } from "@openstatus/ui"; 7 + 8 + import useUpdateSearchParams from "@/hooks/use-update-search-params"; 9 + import { cn } from "@/lib/utils"; 10 + 11 + export function PricingPlanRadio() { 12 + const updateSearchParams = useUpdateSearchParams(); 13 + const router = useRouter(); 14 + return ( 15 + <RadioGroup 16 + defaultValue="team" 17 + className="grid grid-cols-4 gap-4" 18 + onValueChange={(value) => { 19 + const searchParams = updateSearchParams({ plan: value }); 20 + router.replace(`?${searchParams}`, { scroll: false }); 21 + }} 22 + > 23 + {plans.map((key) => ( 24 + <div key={key}> 25 + <RadioGroupItem value={key} id={key} className="peer sr-only" /> 26 + <Label 27 + htmlFor={key} 28 + className={cn( 29 + "border-muted bg-popover hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary flex flex-col items-center justify-between rounded-md border-2 p-4", 30 + key === "team" && "bg-muted/50", 31 + )} 32 + > 33 + <span className="capitalize">{key}</span> 34 + <span className="text-muted-foreground mt-1 text-xs font-light"> 35 + {allPlans[key].price}โ‚ฌ/month 36 + </span> 37 + </Label> 38 + </div> 39 + ))} 40 + </RadioGroup> 41 + ); 42 + }
+175
apps/web/src/components/marketing/pricing/pricing-table.tsx
··· 1 + "use client"; 2 + 3 + import { Fragment } from "react"; 4 + import { useRouter } from "next/navigation"; 5 + import { Check } from "lucide-react"; 6 + 7 + import type { PlanName } from "@openstatus/plans"; 8 + import { 9 + allPlans, 10 + plans as defaultPlans, 11 + pricingTableConfig, 12 + } from "@openstatus/plans"; 13 + import { 14 + Badge, 15 + Button, 16 + Table, 17 + TableBody, 18 + TableCaption, 19 + TableCell, 20 + TableHead, 21 + TableHeader, 22 + TableRow, 23 + } from "@openstatus/ui"; 24 + 25 + import { LoadingAnimation } from "@/components/loading-animation"; 26 + import { cn } from "@/lib/utils"; 27 + 28 + export function PricingTable({ 29 + plans = defaultPlans, 30 + currentPlan, 31 + events, 32 + isLoading, 33 + }: { 34 + plans?: readonly PlanName[]; 35 + currentPlan?: PlanName; 36 + events?: Partial<Record<PlanName, () => void>>; 37 + isLoading?: boolean; 38 + }) { 39 + const router = useRouter(); 40 + const selectedPlans = Object.entries(allPlans) 41 + .filter(([key, _]) => plans.includes(key as keyof typeof allPlans)) 42 + .map(([key, value]) => ({ key: key as keyof typeof allPlans, ...value })); 43 + return ( 44 + <Table className="relative"> 45 + <TableCaption> 46 + A list to compare the different features by plan. 47 + </TableCaption> 48 + <TableHeader> 49 + <TableRow className="hover:bg-background"> 50 + <TableHead className="bg-background px-3 py-3 align-bottom"> 51 + Features comparison 52 + </TableHead> 53 + {selectedPlans.map(({ key, ...plan }) => { 54 + const isCurrentPlan = key === currentPlan; 55 + return ( 56 + <TableHead 57 + key={key} 58 + className={cn( 59 + "text-foreground h-auto px-3 py-3 align-bottom", 60 + key === "team" ? "bg-muted/30" : "bg-background", 61 + )} 62 + > 63 + <p className="font-cal sticky top-0 mb-2 text-2xl"> 64 + {plan.title} 65 + </p> 66 + <p className="text-muted-foreground mb-2 text-sm font-normal"> 67 + {plan.description} 68 + </p> 69 + <p className="mb-2 text-right"> 70 + <span className="font-cal text-xl">{plan.price}โ‚ฌ</span>{" "} 71 + <span className="text-muted-foreground text-sm font-light"> 72 + /month 73 + </span> 74 + </p> 75 + <Button 76 + className="w-full" 77 + size="sm" 78 + variant={key === "team" ? "default" : "outline"} 79 + onClick={() => { 80 + if (events?.[key]) { 81 + return events[key]?.(); 82 + } 83 + return router.push(`/app/sign-up?plan=${key}`); 84 + }} 85 + disabled={isCurrentPlan || isLoading} 86 + > 87 + {isLoading ? ( 88 + <LoadingAnimation 89 + variant={key === "team" ? "default" : "inverse"} 90 + /> 91 + ) : isCurrentPlan ? ( 92 + "Current plan" 93 + ) : ( 94 + "Choose" 95 + )} 96 + </Button> 97 + </TableHead> 98 + ); 99 + })} 100 + </TableRow> 101 + </TableHeader> 102 + <TableBody> 103 + {Object.entries(pricingTableConfig).map( 104 + ([key, { label, features }], i) => { 105 + return ( 106 + <Fragment key={i}> 107 + <TableRow className="bg-muted/50"> 108 + <TableCell 109 + colSpan={selectedPlans.length + 1} 110 + className="p-3 font-medium" 111 + > 112 + {label} 113 + </TableCell> 114 + </TableRow> 115 + {features.map(({ label, value, badge }, i) => { 116 + return ( 117 + <TableRow key={i}> 118 + <TableCell className="gap-1"> 119 + {label}{" "} 120 + {badge ? ( 121 + <Badge variant="secondary">{badge}</Badge> 122 + ) : null} 123 + </TableCell> 124 + {selectedPlans.map((plan, i) => { 125 + const limitValue = plan.limits[value]; 126 + function renderContent() { 127 + if (typeof limitValue === "boolean") { 128 + if (limitValue) { 129 + return ( 130 + <Check className="text-foreground h-4 w-4" /> 131 + ); 132 + } else { 133 + return ( 134 + <span className="text-muted-foreground/50"> 135 + &#8208; 136 + </span> 137 + ); 138 + } 139 + } else if (typeof limitValue === "number") { 140 + return ( 141 + <span className="font-mono">{limitValue}</span> 142 + ); 143 + } else if ( 144 + Array.isArray(limitValue) && 145 + limitValue.length > 0 146 + ) { 147 + return limitValue[0]; 148 + } else { 149 + return limitValue; 150 + } 151 + } 152 + 153 + return ( 154 + <TableCell 155 + key={i} 156 + className={cn( 157 + "p-3", 158 + plan.key === "team" && "bg-muted/30", 159 + )} 160 + > 161 + {renderContent()} 162 + </TableCell> 163 + ); 164 + })} 165 + </TableRow> 166 + ); 167 + })} 168 + </Fragment> 169 + ); 170 + }, 171 + )} 172 + </TableBody> 173 + </Table> 174 + ); 175 + }
+25
apps/web/src/components/marketing/pricing/pricing-wrapper.tsx
··· 1 + "use client"; 2 + 3 + import { useSearchParams } from "next/navigation"; 4 + 5 + import type { PlanName } from "@openstatus/plans"; 6 + 7 + import { PricingPlanRadio } from "./pricing-plan-radio"; 8 + import { PricingTable } from "./pricing-table"; 9 + 10 + export function PricingWrapper() { 11 + const searchParams = useSearchParams(); 12 + return ( 13 + <div> 14 + <div className="flex flex-col gap-4 sm:hidden"> 15 + <PricingPlanRadio /> 16 + <PricingTable 17 + plans={[(searchParams.get("plan") as PlanName) || "team"]} 18 + /> 19 + </div> 20 + <div className="hidden sm:block"> 21 + <PricingTable /> 22 + </div> 23 + </div> 24 + ); 25 + }
-66
apps/web/src/config/plans.ts
··· 1 - export type Plans = "free" | "pro" | "enterprise"; 2 - 3 - export interface PlanProps { 4 - title: string; 5 - description: string; 6 - cost: number | string; 7 - features: string[]; 8 - action?: 9 - | { 10 - text: string; 11 - link: string; 12 - } 13 - | { 14 - text: string; 15 - onClick: () => void; 16 - }; 17 - disabled?: boolean; 18 - loading?: boolean; 19 - } 20 - 21 - export const plansConfig: Record<Plans, PlanProps> = { 22 - free: { 23 - title: "Hobby", 24 - description: "Get started now and upgrade once reaching the limits.", 25 - cost: 0, 26 - features: [ 27 - "5 monitors", 28 - "1 status page", 29 - "subdomain", 30 - "10m, 30m, 1h checks", 31 - "email, slack, discord notifications", 32 - ], 33 - action: { 34 - text: "Start Now", 35 - link: "/app/sign-up?plan=hobby", 36 - }, 37 - }, 38 - pro: { 39 - title: "Pro", 40 - description: "Scale and build monitors for all your services.", 41 - cost: 29, 42 - features: [ 43 - "20 monitors", 44 - "5 status pages", 45 - "custom domain", 46 - "1m, 5m, 10m, 30m, 1h checks", 47 - "email, slack, discord, sms notifications", 48 - "status page subscription", 49 - "5 team members", 50 - ], 51 - action: { 52 - text: "Start Now", 53 - link: "/app/sign-up?plan=pro", 54 - }, 55 - }, 56 - enterprise: { 57 - title: "Enterprise", 58 - description: "Dedicated support and needs for your company.", 59 - cost: "Lets talk", 60 - features: [], 61 - action: { 62 - text: "Schedule call", 63 - link: "https://cal.com/team/openstatus/30min", 64 - }, 65 - }, 66 - };
+1
apps/web/src/middleware.ts
··· 79 79 "/legal/(.*)", 80 80 "/discord", 81 81 "/github", 82 + "/pricing", 82 83 "/oss-friends", 83 84 "/status-page/(.*)", 84 85 "/incidents", // used when trying subdomain slug via status.documenso.com/incidents
+4
apps/web/vercel.json
··· 1 1 { 2 2 "crons": [ 3 3 { 4 + "path": "/api/checker/cron/30s", 5 + "schedule": "* * * * *" 6 + }, 7 + { 4 8 "path": "/api/checker/cron/1m", 5 9 "schedule": "* * * * *" 6 10 },
-4
packages/api/src/env.ts
··· 4 4 export const env = createEnv({ 5 5 server: { 6 6 STRIPE_SECRET_KEY: z.string(), 7 - STRIPE_PRO_PRODUCT_ID: z.string(), 8 - STRIPE_PRO_MONTHLY_PRICE_ID: z.string(), 9 7 PROJECT_ID_VERCEL: z.string(), 10 8 TEAM_ID_VERCEL: z.string(), 11 9 VERCEL_AUTH_BEARER_TOKEN: z.string(), ··· 13 11 14 12 runtimeEnv: { 15 13 STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY, 16 - STRIPE_PRO_PRODUCT_ID: process.env.STRIPE_PRO_PRODUCT_ID, 17 - STRIPE_PRO_MONTHLY_PRICE_ID: process.env.STRIPE_PRO_MONTHLY_PRICE_ID, 18 14 PROJECT_ID_VERCEL: process.env.PROJECT_ID_VERCEL, 19 15 TEAM_ID_VERCEL: process.env.TEAM_ID_VERCEL, 20 16 VERCEL_AUTH_BEARER_TOKEN: process.env.VERCEL_AUTH_BEARER_TOKEN,
+4 -1
packages/api/src/router/invitation.ts
··· 19 19 .mutation(async (opts) => { 20 20 const { email } = opts.input; 21 21 22 - const membersLimit = allPlans[opts.ctx.workspace.plan].limits.members; 22 + const membersLimit = 23 + allPlans[opts.ctx.workspace.plan].limits.members === "unlimited" 24 + ? 420 25 + : 1; 23 26 24 27 const usersToWorkspacesNumbers = ( 25 28 await opts.ctx.db.query.usersToWorkspaces.findMany({
+18 -5
packages/api/src/router/stripe/index.ts
··· 1 1 import { z } from "zod"; 2 2 3 3 import { eq } from "@openstatus/db"; 4 - import { user, usersToWorkspaces, workspace } from "@openstatus/db/src/schema"; 4 + import { 5 + user, 6 + usersToWorkspaces, 7 + workspace, 8 + workspacePlans, 9 + } from "@openstatus/db/src/schema"; 5 10 6 11 import { createTRPCRouter, protectedProcedure } from "../../trpc"; 7 12 import { stripe } from "./shared"; 13 + import { getPriceIdForPlan, PLANS } from "./utils"; 8 14 import { webhookRouter } from "./webhook"; 9 15 10 16 const url = ··· 70 76 }), 71 77 72 78 getCheckoutSession: protectedProcedure 73 - .input(z.object({ workspaceSlug: z.string() })) 79 + .input( 80 + z.object({ 81 + workspaceSlug: z.string(), 82 + plan: z.enum(workspacePlans), 83 + // TODO: plan: workspacePlanSchema 84 + }), 85 + ) 74 86 .mutation(async (opts) => { 75 87 console.log("getCheckoutSession"); 76 88 // The following code is duplicated we should extract it ··· 121 133 .run(); 122 134 } 123 135 136 + const priceId = getPriceIdForPlan(opts.input.plan); 124 137 const session = await stripe.checkout.sessions.create({ 125 138 payment_method_types: ["card"], 126 139 customer: stripeId, 127 140 128 141 line_items: [ 129 142 { 130 - price: process.env.STRIPE_PRO_MONTHLY_PRICE_ID, 143 + price: priceId, 131 144 quantity: 1, 132 145 }, 133 146 ], 134 147 mode: "subscription", 135 - success_url: `${url}/app/${result.slug}/settings?success=true`, 136 - cancel_url: `${url}/app/${result.slug}/settings`, 148 + success_url: `${url}/app/${result.slug}/settings/billing?success=true`, 149 + cancel_url: `${url}/app/${result.slug}/settings/billing`, 137 150 }); 138 151 139 152 return session;
+55
packages/api/src/router/stripe/utils.ts
··· 1 + // Shamelessly stolen from dub.co 2 + 3 + import { WorkspacePlan } from "@openstatus/db/src/schema"; 4 + 5 + export const getPlanFromPriceId = (priceId: string) => { 6 + const env = 7 + process.env.NEXT_PUBLIC_VERCEL_ENV === "production" ? "production" : "test"; 8 + return PLANS.find((plan) => plan.price.monthly.priceIds[env] === priceId)!; 9 + }; 10 + 11 + export const getPriceIdForPlan = (plan: WorkspacePlan) => { 12 + const env = 13 + process.env.NEXT_PUBLIC_VERCEL_ENV === "production" ? "production" : "test"; 14 + return PLANS.find((p) => p.plan === plan)!.price.monthly.priceIds[env]; 15 + }; 16 + export const PLANS = [ 17 + { 18 + plan: "pro", 19 + price: { 20 + monthly: { 21 + priceIds: { 22 + test: "price_1NdurjBXJcTfzsyJdAzIxXnT", 23 + production: "price_1OUvJvBXJcTfzsyJMA07Uew7", 24 + }, 25 + }, 26 + }, 27 + }, 28 + { 29 + plan: "team", 30 + price: { 31 + monthly: { 32 + priceIds: { 33 + test: "price_1OVHQDBXJcTfzsyJjfiXl10Y", 34 + production: "price_1Nec6SBXJcTfzsyJsfDFiBIB", 35 + }, 36 + }, 37 + }, 38 + }, 39 + { 40 + plan: "starter", 41 + price: { 42 + monthly: { 43 + priceIds: { 44 + test: "price_1OVHPlBXJcTfzsyJvPlB1kNb", 45 + production: "price_1OUvGWBXJcTfzsyJGeCDDAJV", 46 + }, 47 + }, 48 + }, 49 + }, 50 + ] satisfies Array<{ 51 + plan: WorkspacePlan; 52 + price: { 53 + monthly: { priceIds: { test: string; production: string } }; 54 + }; 55 + }>;
+4 -1
packages/api/src/router/stripe/webhook.ts
··· 8 8 9 9 import { createTRPCRouter, publicProcedure } from "../../trpc"; 10 10 import { stripe } from "./shared"; 11 + import { getPlanFromPriceId } from "./utils"; 11 12 12 13 const webhookProcedure = publicProcedure.input( 13 14 z.object({ ··· 51 52 message: "Workspace not found", 52 53 }); 53 54 } 55 + const plan = getPlanFromPriceId(subscription.items.data[0].price.id); 56 + 54 57 await opts.ctx.db 55 58 .update(workspace) 56 59 .set({ 57 - plan: "pro", 60 + plan: plan.plan, 58 61 subscriptionId: subscription.id, 59 62 endsAt: new Date(subscription.current_period_end * 1000), 60 63 paidUntil: new Date(subscription.current_period_end * 1000),
+48
packages/api/src/router/workspace.ts
··· 1 + import { TRPCError } from "@trpc/server"; 1 2 import { generateSlug } from "random-word-slugs"; 2 3 import { z } from "zod"; 3 4 ··· 7 8 user, 8 9 usersToWorkspaces, 9 10 workspace, 11 + workspacePlanSchema, 10 12 } from "@openstatus/db/src/schema"; 11 13 12 14 import { createTRPCRouter, protectedProcedure } from "../trpc"; ··· 93 95 ), 94 96 ) 95 97 .run(); 98 + }), 99 + 100 + changePlan: protectedProcedure 101 + .input(z.object({ plan: workspacePlanSchema })) 102 + .mutation(async (opts) => { 103 + const _userToWorkspace = 104 + await opts.ctx.db.query.usersToWorkspaces.findFirst({ 105 + where: and( 106 + eq(usersToWorkspaces.userId, opts.ctx.user.id), 107 + eq(usersToWorkspaces.workspaceId, opts.ctx.workspace.id), 108 + ), 109 + }); 110 + 111 + if (!_userToWorkspace) throw new Error("No user to workspace found"); 112 + 113 + if (!["owner"].includes(_userToWorkspace.role)) 114 + throw new TRPCError({ 115 + code: "PRECONDITION_FAILED", 116 + message: "Not authorized to change plan", 117 + }); 118 + 119 + if (!opts.ctx.workspace.stripeId) { 120 + throw new TRPCError({ 121 + code: "PRECONDITION_FAILED", 122 + message: "No Stripe ID found for workspace", 123 + }); 124 + } 125 + 126 + // TODO: Create subscription 127 + switch (opts.input.plan) { 128 + case "free": { 129 + } 130 + case "starter": { 131 + } 132 + case "team": { 133 + } 134 + case "pro": { 135 + } 136 + default: { 137 + } 138 + } 139 + 140 + await opts.ctx.db 141 + .update(workspace) 142 + .set({ plan: opts.input.plan }) 143 + .where(eq(workspace.id, opts.ctx.workspace.id)); 96 144 }), 97 145 98 146 createWorkspace: protectedProcedure
+1
packages/db/src/schema/monitors/constants.ts
··· 25 25 export const flyRegions = ["ams", "iad", "hkg", "jnb", "syd", "gru"] as const; 26 26 27 27 export const monitorPeriodicity = [ 28 + "30s", 28 29 "1m", 29 30 "5m", 30 31 "10m",
+1 -1
packages/db/src/schema/workspaces/constants.ts
··· 1 - export const workspacePlans = ["free", "pro"] as const; 1 + export const workspacePlans = ["free", "starter", "team", "pro"] as const; 2 2 export const workspaceRole = ["owner", "admin", "member"] as const;
+103 -13
packages/plans/index.ts
··· 1 1 import type { MonitorPeriodicity } from "@openstatus/db/src/schema"; 2 2 3 + export const plans = ["free", "starter", "team", "pro"] as const; 4 + export type PlanName = (typeof plans)[number]; 5 + 6 + export type Limits = { 7 + // monitors 8 + monitors: number; 9 + periodicity: Partial<MonitorPeriodicity>[]; 10 + "multi-region": boolean; 11 + "data-retention": string; 12 + // status pages 13 + "status-pages": number; 14 + "status-subscribers": boolean; 15 + "custom-domain": boolean; 16 + "white-label": boolean; 17 + // alerts 18 + notifications: boolean; 19 + sms: boolean; 20 + "notification-channels": number; 21 + // collaboration 22 + members: string; 23 + "audit-log": boolean; 24 + }; 25 + 26 + export type FeatureKey = keyof Limits; 27 + 3 28 export type Plan = { 4 - limits: { 5 - monitors: number; 6 - "status-pages": number; 7 - periodicity: Partial<MonitorPeriodicity>[]; 8 - members: number; 9 - }; 29 + title: string; 30 + description: string; 31 + price: number; 32 + limits: Limits; 10 33 }; 11 34 12 - export const allPlans: Record<"free" | "pro", Plan> = { 35 + // TODO: rename to `planConfig` 36 + export const allPlans: Record<PlanName, Plan> = { 13 37 free: { 38 + title: "Hobby", 39 + description: "For personal projects", 40 + price: 0, 14 41 limits: { 15 - monitors: 5, 42 + monitors: 3, 43 + periodicity: ["10m", "30m", "1h"], 44 + "multi-region": true, 45 + "data-retention": "14 days", 16 46 "status-pages": 1, 17 - periodicity: ["10m", "30m", "1h"], 18 - members: 1, 47 + "status-subscribers": false, 48 + "custom-domain": false, 49 + "white-label": false, 50 + notifications: true, 51 + sms: false, 52 + "notification-channels": 1, 53 + members: "1", 54 + "audit-log": false, 19 55 }, 20 56 }, 21 - pro: { 57 + starter: { 58 + title: "Starter", 59 + description: "For small projects", 60 + price: 9, 61 + limits: { 62 + monitors: 10, 63 + periodicity: ["1m", "5m", "10m", "30m", "1h"], 64 + "multi-region": true, 65 + "data-retention": "3 months", 66 + "status-pages": 1, 67 + "status-subscribers": true, 68 + "custom-domain": true, 69 + "white-label": false, 70 + notifications: true, 71 + sms: false, 72 + "notification-channels": 3, 73 + members: "Unlimited", 74 + "audit-log": false, 75 + }, 76 + }, 77 + team: { 78 + title: "Team", 79 + description: "For small teams", 80 + price: 29, 22 81 limits: { 23 82 monitors: 20, 83 + periodicity: ["1m", "5m", "10m", "30m", "1h"], 84 + "multi-region": true, 85 + "data-retention": "12 months", 24 86 "status-pages": 5, 25 - periodicity: ["1m", "5m", "10m", "30m", "1h"], 26 - members: 5, 87 + "status-subscribers": true, 88 + "custom-domain": true, 89 + "white-label": false, 90 + notifications: true, 91 + sms: true, 92 + "notification-channels": 10, 93 + members: "Unlimited", 94 + "audit-log": true, 95 + }, 96 + }, 97 + pro: { 98 + title: "Pro", 99 + description: "For bigger teams", 100 + price: 99, 101 + limits: { 102 + monitors: 100, 103 + periodicity: ["30s", "1m", "5m", "10m", "30m", "1h"], 104 + "multi-region": true, 105 + "data-retention": "24 months", 106 + "status-pages": 10, 107 + "status-subscribers": true, 108 + "custom-domain": true, 109 + "white-label": true, 110 + notifications: true, 111 + sms: true, 112 + "notification-channels": 20, 113 + members: "Unlimited", 114 + "audit-log": true, 27 115 }, 28 116 }, 29 117 }; 118 + 119 + export { pricingTableConfig } from "./pricing-table";
+82
packages/plans/pricing-table.ts
··· 1 + import type { FeatureKey } from "./index"; 2 + 3 + type TableConfig = Record< 4 + string, 5 + { 6 + label: string; 7 + features: { value: FeatureKey; label: string; badge?: string }[]; 8 + } 9 + >; 10 + 11 + export const pricingTableConfig: TableConfig = { 12 + monitors: { 13 + label: "Monitors", 14 + features: [ 15 + { 16 + value: "periodicity", 17 + label: "Frequency", 18 + }, 19 + { 20 + value: "monitors", 21 + label: "Number of monitors", 22 + }, 23 + { 24 + value: "multi-region", 25 + label: "Multi-region monitoring", 26 + }, 27 + { value: "data-retention", label: "Data retention" }, 28 + ], 29 + }, 30 + "status-pages": { 31 + label: "Status Pages", 32 + features: [ 33 + { 34 + value: "status-pages", 35 + label: "Number of status pages", 36 + }, 37 + { 38 + value: "status-subscribers", 39 + label: "Subscribers", 40 + }, 41 + { 42 + value: "custom-domain", 43 + label: "Custom domain", 44 + }, 45 + { 46 + value: "white-label", 47 + label: "White Label", 48 + }, 49 + ], 50 + }, 51 + alerts: { 52 + label: "Alerts", 53 + features: [ 54 + { 55 + value: "notifications", 56 + label: "Slack, Discord, Email", 57 + }, 58 + { 59 + value: "sms", 60 + label: "SMS", 61 + }, 62 + { 63 + value: "notification-channels", 64 + label: "Number of notification channels", 65 + }, 66 + ], 67 + }, 68 + collaboration: { 69 + label: "Collaboration", 70 + features: [ 71 + { 72 + value: "members", 73 + label: "Team members", 74 + }, 75 + { 76 + value: "audit-log", 77 + label: "Audit log", 78 + badge: "Planned", 79 + }, 80 + ], 81 + }, 82 + };