Openstatus www.openstatus.dev
6
fork

Configure Feed

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

feat: improved status-pages layout (#557)

* chore: improve status page layout

* fix: typo and limit on new pages

* fix: href

authored by

Maximilian Kaske and committed by
GitHub
73a2d6ac e96ff6d1

+348 -369
+5
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/new/page.tsx
··· 1 + import { redirect } from "next/navigation"; 2 + 1 3 import { MonitorForm } from "@/components/forms/monitor-form"; 2 4 import { api } from "@/trpc/server"; 3 5 ··· 5 7 const workspace = await api.workspace.getWorkspace.query(); 6 8 const notifications = 7 9 await api.notification.getNotificationsByWorkspace.query(); 10 + const isLimitReached = await api.monitor.isMonitorLimitReached.query(); 11 + 12 + if (isLimitReached) return redirect("./"); 8 13 9 14 return ( 10 15 <MonitorForm
+38
apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-pages/(overview)/layout.tsx
··· 1 + import * as React from "react"; 2 + import Link from "next/link"; 3 + 4 + import { ButtonWithDisableTooltip } from "@openstatus/ui"; 5 + 6 + import { Header } from "@/components/dashboard/header"; 7 + import { HelpCallout } from "@/components/dashboard/help-callout"; 8 + import { api } from "@/trpc/server"; 9 + 10 + export default async function Layout({ 11 + children, 12 + }: { 13 + children: React.ReactNode; 14 + }) { 15 + const isLimitReached = await api.page.isPageLimitReached.query(); 16 + 17 + return ( 18 + <div className="grid min-h-full grid-cols-1 grid-rows-[auto,1fr,auto] gap-6 md:grid-cols-2 md:gap-8"> 19 + <Header 20 + title="Pages" 21 + description="Overview of all your pages." 22 + actions={ 23 + <ButtonWithDisableTooltip 24 + tooltip="You reached the limits" 25 + asChild={!isLimitReached} 26 + disabled={isLimitReached} 27 + > 28 + <Link href="./status-pages/new">Create</Link> 29 + </ButtonWithDisableTooltip> 30 + } 31 + /> 32 + <div className="col-span-full">{children}</div> 33 + <div className="mt-8 md:mt-12"> 34 + <HelpCallout /> 35 + </div> 36 + </div> 37 + ); 38 + }
+5
apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-pages/(overview)/loading.tsx
··· 1 + import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"; 2 + 3 + export default function Loading() { 4 + return <DataTableSkeleton />; 5 + }
+36
apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-pages/(overview)/page.tsx
··· 1 + import * as React from "react"; 2 + import Link from "next/link"; 3 + 4 + import { Button } from "@openstatus/ui"; 5 + 6 + import { EmptyState } from "@/components/dashboard/empty-state"; 7 + import { Limit } from "@/components/dashboard/limit"; 8 + import { columns } from "@/components/data-table/status-page/columns"; 9 + import { DataTable } from "@/components/data-table/status-page/data-table"; 10 + import { api } from "@/trpc/server"; 11 + 12 + export default async function MonitorPage() { 13 + const pages = await api.page.getPagesByWorkspace.query(); 14 + const isLimitReached = await api.page.isPageLimitReached.query(); 15 + 16 + if (pages?.length === 0) 17 + return ( 18 + <EmptyState 19 + icon="panel-top" 20 + title="No pages" 21 + description="Create your first page" 22 + action={ 23 + <Button asChild> 24 + <Link href="./status-pages/new">Create</Link> 25 + </Button> 26 + } 27 + /> 28 + ); 29 + 30 + return ( 31 + <> 32 + <DataTable columns={columns} data={pages} /> 33 + <div className="mt-3">{isLimitReached ? <Limit /> : null}</div> 34 + </> 35 + ); 36 + }
+5
apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-pages/[id]/domain/loading.tsx
··· 1 + import { SkeletonForm } from "@/components/forms/skeleton-form"; 2 + 3 + export default function Loading() { 4 + return <SkeletonForm />; 5 + }
+30
apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-pages/[id]/domain/page.tsx
··· 1 + import { notFound } from "next/navigation"; 2 + 3 + import { ProFeatureAlert } from "@/components/billing/pro-feature-alert"; 4 + import { CustomDomainForm } from "@/components/forms/custom-domain-form"; 5 + import { api } from "@/trpc/server"; 6 + 7 + export default async function CustomDomainPage({ 8 + params, 9 + }: { 10 + params: { workspaceSlug: string; id: string }; 11 + }) { 12 + const id = Number(params.id); 13 + const page = await api.page.getPageById.query({ id }); 14 + const workspace = await api.workspace.getWorkspace.query(); 15 + 16 + const isProPlan = workspace?.plan === "pro"; 17 + 18 + if (!page) return notFound(); 19 + 20 + if (!isProPlan) return <ProFeatureAlert feature="Custom domains" />; 21 + 22 + return ( 23 + <CustomDomainForm 24 + defaultValues={{ 25 + customDomain: page.customDomain, 26 + id: page.id, 27 + }} 28 + /> 29 + ); 30 + }
+5
apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-pages/[id]/edit/loading.tsx
··· 1 + import { SkeletonForm } from "@/components/forms/skeleton-form"; 2 + 3 + export default function Loading() { 4 + return <SkeletonForm />; 5 + }
+28
apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-pages/[id]/edit/page.tsx
··· 1 + import { notFound } from "next/navigation"; 2 + 3 + import { StatusPageForm } from "@/components/forms/status-page-form"; 4 + import { api } from "@/trpc/server"; 5 + 6 + export default async function EditPage({ 7 + params, 8 + }: { 9 + params: { workspaceSlug: string; id: string }; 10 + }) { 11 + const id = Number(params.id); 12 + const page = await api.page.getPageById.query({ id }); 13 + const allMonitors = await api.monitor.getMonitorsByWorkspace.query(); 14 + 15 + if (!page) { 16 + return notFound(); 17 + } 18 + 19 + return ( 20 + <StatusPageForm 21 + allMonitors={allMonitors} 22 + defaultValues={{ 23 + ...page, 24 + monitors: page.monitorsToPages.map(({ monitor }) => monitor.id), 25 + }} 26 + /> 27 + ); 28 + }
+60
apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-pages/[id]/layout.tsx
··· 1 + import Link from "next/link"; 2 + import { notFound } from "next/navigation"; 3 + 4 + import { Button } from "@openstatus/ui"; 5 + 6 + import { Header } from "@/components/dashboard/header"; 7 + import { Navbar } from "@/components/dashboard/navbar"; 8 + import { api } from "@/trpc/server"; 9 + 10 + export default async function Layout({ 11 + children, 12 + params, 13 + }: { 14 + children: React.ReactNode; 15 + params: { workspaceSlug: string; id: string }; 16 + }) { 17 + const id = params.id; 18 + 19 + const page = await api.page.getPageById.query({ 20 + id: Number(id), 21 + }); 22 + 23 + if (!page) { 24 + return notFound(); 25 + } 26 + 27 + const navigation = [ 28 + { 29 + label: "Settings", 30 + href: `/app/${params.workspaceSlug}/status-pages/${id}/edit`, 31 + segment: "edit", 32 + }, 33 + { 34 + label: "Domain", 35 + href: `/app/${params.workspaceSlug}/status-pages/${id}/domain`, 36 + segment: "domain", 37 + }, 38 + { 39 + label: "Subscribers", 40 + href: `/app/${params.workspaceSlug}/status-pages/${id}/subscribers`, 41 + segment: "subscribers", 42 + }, 43 + ]; 44 + 45 + return ( 46 + <div className="grid grid-cols-1 gap-6 md:gap-8"> 47 + <Header 48 + title={page.title} 49 + description={page.description} 50 + actions={ 51 + <Button variant="outline" asChild> 52 + <Link href={`https://${page.slug}.openstatus.dev`}>Visit</Link> 53 + </Button> 54 + } 55 + /> 56 + <Navbar className="col-span-full" navigation={navigation} /> 57 + {children} 58 + </div> 59 + ); 60 + }
+9
apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-pages/[id]/page.tsx
··· 1 + import { redirect } from "next/navigation"; 2 + 3 + export default function Page({ 4 + params, 5 + }: { 6 + params: { workspaceSlug: string; id: string }; 7 + }) { 8 + return redirect(`./${params.id}/edit`); 9 + }
+5
apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-pages/[id]/subscribers/loading.tsx
··· 1 + import { SkeletonForm } from "@/components/forms/skeleton-form"; 2 + 3 + export default function Loading() { 4 + return <SkeletonForm />; 5 + }
+29
apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-pages/[id]/subscribers/page.tsx
··· 1 + import { notFound } from "next/navigation"; 2 + 3 + import { ProFeatureAlert } from "@/components/billing/pro-feature-alert"; 4 + import { api } from "@/trpc/server"; 5 + 6 + export default async function CustomDomainPage({ 7 + params, 8 + }: { 9 + params: { workspaceSlug: string; id: string }; 10 + }) { 11 + const id = Number(params.id); 12 + const page = await api.page.getPageById.query({ id }); 13 + const workspace = await api.workspace.getWorkspace.query(); 14 + 15 + const isProPlan = workspace?.plan === "pro"; 16 + 17 + if (!page) return notFound(); 18 + 19 + if (!isProPlan) 20 + return <ProFeatureAlert feature={"Status page subscribers"} />; 21 + 22 + // TODO: add page-subscribers trpc endpoint first 23 + return ( 24 + <p className="text-muted-foreground text-sm"> 25 + Your users can subscribe to status report updates. A list with more 26 + detailed informations coming soon. 27 + </p> 28 + ); 29 + }
-113
apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-pages/_components/action-button.tsx
··· 1 - "use client"; 2 - 3 - import * as React from "react"; 4 - import Link from "next/link"; 5 - import { useRouter } from "next/navigation"; 6 - import { MoreVertical } from "lucide-react"; 7 - 8 - import type { Page } from "@openstatus/db/src/schema"; 9 - import { 10 - AlertDialog, 11 - AlertDialogAction, 12 - AlertDialogCancel, 13 - AlertDialogContent, 14 - AlertDialogDescription, 15 - AlertDialogFooter, 16 - AlertDialogHeader, 17 - AlertDialogTitle, 18 - AlertDialogTrigger, 19 - Button, 20 - DropdownMenu, 21 - DropdownMenuContent, 22 - DropdownMenuItem, 23 - DropdownMenuTrigger, 24 - } from "@openstatus/ui"; 25 - 26 - import { LoadingAnimation } from "@/components/loading-animation"; 27 - import { useToastAction } from "@/hooks/use-toast-action"; 28 - import { api } from "@/trpc/client"; 29 - 30 - interface ActionButtonProps { 31 - page: Page; 32 - } 33 - 34 - export function ActionButton({ page }: ActionButtonProps) { 35 - const router = useRouter(); 36 - const { toast } = useToastAction(); 37 - const [alertOpen, setAlertOpen] = React.useState(false); 38 - const [isPending, startTransition] = React.useTransition(); 39 - 40 - async function onDelete() { 41 - startTransition(async () => { 42 - try { 43 - if (!page.id) return; 44 - await api.page.delete.mutate({ id: page.id }); 45 - toast("deleted"); 46 - router.refresh(); 47 - setAlertOpen(false); 48 - } catch { 49 - toast("error"); 50 - } 51 - }); 52 - } 53 - 54 - return ( 55 - <AlertDialog open={alertOpen} onOpenChange={(value) => setAlertOpen(value)}> 56 - <DropdownMenu> 57 - <DropdownMenuTrigger asChild> 58 - <Button 59 - variant="ghost" 60 - className="data-[state=open]:bg-accent h-8 w-8 p-0" 61 - > 62 - <span className="sr-only">Open menu</span> 63 - <MoreVertical className="h-4 w-4" /> 64 - </Button> 65 - </DropdownMenuTrigger> 66 - <DropdownMenuContent align="end"> 67 - <Link href={`./status-pages/edit?id=${page.id}`}> 68 - <DropdownMenuItem>Edit</DropdownMenuItem> 69 - </Link> 70 - <Link 71 - // TODO: it would be create to extract this logic and include custom domains if they are set 72 - // similar to `setPrefixUrl` 73 - href={ 74 - process.env.NODE_ENV === "production" 75 - ? `https://${page.slug}.openstatus.dev` 76 - : `/status-page/${page.slug}` 77 - } 78 - target="_blank" 79 - > 80 - <DropdownMenuItem>Visit</DropdownMenuItem> 81 - </Link> 82 - <AlertDialogTrigger asChild> 83 - <DropdownMenuItem className="text-destructive focus:bg-destructive focus:text-background"> 84 - Delete 85 - </DropdownMenuItem> 86 - </AlertDialogTrigger> 87 - </DropdownMenuContent> 88 - </DropdownMenu> 89 - <AlertDialogContent> 90 - <AlertDialogHeader> 91 - <AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle> 92 - <AlertDialogDescription> 93 - This action cannot be undone. This will permanently delete the 94 - monitor. 95 - </AlertDialogDescription> 96 - </AlertDialogHeader> 97 - <AlertDialogFooter> 98 - <AlertDialogCancel>Cancel</AlertDialogCancel> 99 - <AlertDialogAction 100 - onClick={(e) => { 101 - e.preventDefault(); 102 - onDelete(); 103 - }} 104 - disabled={isPending} 105 - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" 106 - > 107 - {!isPending ? "Delete" : <LoadingAnimation />} 108 - </AlertDialogAction> 109 - </AlertDialogFooter> 110 - </AlertDialogContent> 111 - </AlertDialog> 112 - ); 113 - }
-35
apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-pages/_components/empty-state.tsx
··· 1 - import Link from "next/link"; 2 - 3 - import type { Monitor } from "@openstatus/db/src/schema"; 4 - import { Button } from "@openstatus/ui"; 5 - 6 - import { EmptyState as DefaultEmptyState } from "@/components/dashboard/empty-state"; 7 - 8 - export function EmptyState({ allMonitors }: { allMonitors?: Monitor[] }) { 9 - if (!Boolean(allMonitors?.length)) { 10 - return ( 11 - <DefaultEmptyState 12 - icon="panel-top" 13 - title="No pages" 14 - description="First create a monitor before creating a page." 15 - action={ 16 - <Button asChild> 17 - <Link href="./monitors/edit">Create a monitor</Link> 18 - </Button> 19 - } 20 - /> 21 - ); 22 - } 23 - return ( 24 - <DefaultEmptyState 25 - icon="panel-top" 26 - title="No pages" 27 - description="Create your first page." 28 - action={ 29 - <Button asChild> 30 - <Link href="./status-pages/edit">Create</Link> 31 - </Button> 32 - } 33 - /> 34 - ); 35 - }
-24
apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-pages/edit/_components/pro-feature-alert.tsx
··· 1 - import Link from "next/link"; 2 - import { AlertTriangle } from "lucide-react"; 3 - 4 - import { Alert, AlertDescription, AlertTitle } from "@openstatus/ui"; 5 - 6 - export function ProFeatureAlert() { 7 - return ( 8 - <Alert> 9 - <AlertTriangle className="h-4 w-4" /> 10 - <AlertTitle>Custom domains are a Pro feature.</AlertTitle> 11 - <AlertDescription> 12 - If you want to use your own domain, please upgrade to the Pro plan. Go 13 - to{" "} 14 - <Link 15 - href="../settings/billing" 16 - className="text-foreground inline-flex items-center font-medium underline underline-offset-4 hover:no-underline" 17 - > 18 - settings 19 - </Link> 20 - . 21 - </AlertDescription> 22 - </Alert> 23 - ); 24 - }
-19
apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-pages/edit/loading.tsx
··· 1 - import { Skeleton } from "@openstatus/ui"; 2 - 3 - import { Header } from "@/components/dashboard/header"; 4 - import { SkeletonForm } from "@/components/forms/skeleton-form"; 5 - 6 - export default function Loading() { 7 - return ( 8 - <div className="grid gap-6 md:grid-cols-2 md:gap-8"> 9 - <div className="col-span-full flex w-full justify-between"> 10 - <Header.Skeleton> 11 - <Skeleton className="h-9 w-20" /> 12 - </Header.Skeleton> 13 - </div> 14 - <div className="col-span-full"> 15 - <SkeletonForm /> 16 - </div> 17 - </div> 18 - ); 19 - }
-93
apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-pages/edit/page.tsx
··· 1 - import { notFound } from "next/navigation"; 2 - import * as z from "zod"; 3 - 4 - import { Tabs, TabsContent, TabsList, TabsTrigger } from "@openstatus/ui"; 5 - 6 - import { Header } from "@/components/dashboard/header"; 7 - import { CustomDomainForm } from "@/components/forms/custom-domain-form"; 8 - import { StatusPageForm } from "@/components/forms/status-page-form"; 9 - import { api } from "@/trpc/server"; 10 - import { ProFeatureAlert } from "./_components/pro-feature-alert"; 11 - 12 - /** 13 - * allowed URL search params 14 - */ 15 - const searchParamsSchema = z.object({ 16 - id: z.coerce.number().optional(), 17 - }); 18 - 19 - export default async function EditPage({ 20 - params, 21 - searchParams, 22 - }: { 23 - params: { workspaceSlug: string }; 24 - searchParams: { [key: string]: string | string[] | undefined }; 25 - }) { 26 - const search = searchParamsSchema.safeParse(searchParams); 27 - 28 - if (!search.success) { 29 - return notFound(); 30 - } 31 - 32 - const { id } = search.data; 33 - 34 - // TODO: too many requests to db 35 - const page = id ? await api.page.getPageById.query({ id }) : undefined; 36 - const monitors = await api.monitor.getMonitorsByWorkspace.query(); 37 - const workspace = await api.workspace.getWorkspace.query(); 38 - 39 - const isProPlan = workspace?.plan === "pro"; 40 - 41 - return ( 42 - <div className="grid gap-6 md:grid-cols-2 md:gap-8"> 43 - <Header title="Status Page" description="Upsert your status page." /> 44 - <div className="col-span-full"> 45 - {/* TODO: add same structure for skeleton to loading.tsx */} 46 - <Tabs defaultValue="settings" className="relative mr-auto w-full"> 47 - <TabsList className="h-9 w-full justify-start rounded-none border-b bg-transparent p-0"> 48 - <TabsTrigger 49 - className="text-muted-foreground data-[state=active]:border-b-primary data-[state=active]:text-foreground relative h-9 rounded-none border-b-2 border-b-transparent bg-transparent px-4 pb-3 pt-2 font-semibold shadow-none transition-none data-[state=active]:shadow-none" 50 - value="settings" 51 - > 52 - Settings 53 - </TabsTrigger> 54 - <TabsTrigger 55 - className="text-muted-foreground data-[state=active]:border-b-primary data-[state=active]:text-foreground relative h-9 rounded-none border-b-2 border-b-transparent bg-transparent px-4 pb-3 pt-2 font-semibold shadow-none transition-none data-[state=active]:shadow-none" 56 - value="domain" 57 - disabled={!page} 58 - > 59 - Domain 60 - </TabsTrigger> 61 - </TabsList> 62 - <TabsContent value="settings" className="pt-3"> 63 - <StatusPageForm 64 - allMonitors={monitors} 65 - defaultValues={ 66 - page 67 - ? { 68 - ...page, 69 - monitors: page.monitorsToPages.map( 70 - ({ monitor }) => monitor.id, 71 - ), 72 - } 73 - : undefined 74 - } 75 - /> 76 - </TabsContent> 77 - <TabsContent value="domain" className="pt-3"> 78 - {page && isProPlan ? ( 79 - <CustomDomainForm 80 - defaultValues={{ 81 - customDomain: page?.customDomain, 82 - id: page?.id, 83 - }} // to be improved 84 - /> 85 - ) : ( 86 - <ProFeatureAlert /> 87 - )} 88 - </TabsContent> 89 - </Tabs> 90 - </div> 91 - </div> 92 - ); 93 - }
-19
apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-pages/loading.tsx
··· 1 - import { Skeleton } from "@openstatus/ui"; 2 - 3 - import { Header } from "@/components/dashboard/header"; 4 - import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"; 5 - 6 - export default function Loading() { 7 - return ( 8 - <div className="grid gap-6 md:grid-cols-2 md:gap-8"> 9 - <div className="col-span-full flex w-full justify-between"> 10 - <Header.Skeleton> 11 - <Skeleton className="h-9 w-20" /> 12 - </Header.Skeleton> 13 - </div> 14 - <div className="col-span-full w-full"> 15 - <DataTableSkeleton /> 16 - </div> 17 - </div> 18 - ); 19 - }
+14
apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-pages/new/layout.tsx
··· 1 + import { Header } from "@/components/dashboard/header"; 2 + 3 + export default async function Layout({ 4 + children, 5 + }: { 6 + children: React.ReactNode; 7 + }) { 8 + return ( 9 + <div className="grid gap-6 md:grid-cols-2 md:gap-8"> 10 + <Header title="Pages" description="Create your page." /> 11 + <div className="col-span-full">{children}</div> 12 + </div> 13 + ); 14 + }
+5
apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-pages/new/loading.tsx
··· 1 + import { SkeletonForm } from "@/components/forms/skeleton-form"; 2 + 3 + export default function Loading() { 4 + return <SkeletonForm />; 5 + }
+18
apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-pages/new/page.tsx
··· 1 + import { redirect } from "next/navigation"; 2 + 3 + import { StatusPageForm } from "@/components/forms/status-page-form"; 4 + import { api } from "@/trpc/server"; 5 + 6 + export default async function Page() { 7 + const allMonitors = await api.monitor.getMonitorsByWorkspace.query(); 8 + const isLimitReached = await api.page.isPageLimitReached.query(); 9 + 10 + if (isLimitReached) return redirect("./"); 11 + 12 + return ( 13 + <StatusPageForm 14 + allMonitors={allMonitors} // FIXME: rename to just 'monitors' 15 + nextUrl="./" // back to the overview page 16 + /> 17 + ); 18 + }
-64
apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-pages/page.tsx
··· 1 - import * as React from "react"; 2 - import Link from "next/link"; 3 - 4 - import { allPlans } from "@openstatus/plans"; 5 - import { ButtonWithDisableTooltip } from "@openstatus/ui"; 6 - 7 - import { Header } from "@/components/dashboard/header"; 8 - import { HelpCallout } from "@/components/dashboard/help-callout"; 9 - import { Limit } from "@/components/dashboard/limit"; 10 - import { columns } from "@/components/data-table/status-page/columns"; 11 - import { DataTable } from "@/components/data-table/status-page/data-table"; 12 - import { api } from "@/trpc/server"; 13 - import { EmptyState } from "./_components/empty-state"; 14 - 15 - // export const revalidate = 0; 16 - export const dynamic = "force-dynamic"; 17 - 18 - export default async function Page({ 19 - params, 20 - }: { 21 - params: { workspaceSlug: string }; 22 - }) { 23 - const pages = await api.page.getPagesByWorkspace.query(); 24 - const monitors = await api.monitor.getMonitorsByWorkspace.query(); 25 - 26 - const workspace = await api.workspace.getWorkspace.query(); 27 - 28 - const isLimit = 29 - (pages?.length || 0) >= 30 - allPlans[workspace?.plan || "free"].limits["status-pages"]; 31 - 32 - const disableButton = isLimit || !Boolean(monitors); 33 - 34 - return ( 35 - <div className="grid min-h-full grid-cols-1 grid-rows-[auto,1fr,auto] gap-6 md:grid-cols-2 md:gap-8"> 36 - <Header 37 - title="Status Page" 38 - description="Overview of all your status pages." 39 - actions={ 40 - <ButtonWithDisableTooltip 41 - tooltip="You reached the limits" 42 - asChild={!disableButton} 43 - disabled={disableButton} 44 - > 45 - <Link href="./status-pages/edit">Create</Link> 46 - </ButtonWithDisableTooltip> 47 - } 48 - /> 49 - {Boolean(pages?.length) ? ( 50 - <div className="col-span-full"> 51 - {pages && <DataTable columns={columns} data={pages} />} 52 - <div className="mt-3">{isLimit ? <Limit /> : null}</div> 53 - </div> 54 - ) : ( 55 - <div className="col-span-full"> 56 - <EmptyState allMonitors={monitors} /> 57 - </div> 58 - )} 59 - <div className="mt-8 md:mt-12"> 60 - <HelpCallout /> 61 - </div> 62 - </div> 63 - ); 64 - }
+33
apps/web/src/components/billing/pro-feature-alert.tsx
··· 1 + "use client"; 2 + 3 + import Link from "next/link"; 4 + import { useParams } from "next/navigation"; 5 + import { AlertTriangle } from "lucide-react"; 6 + 7 + import { Alert, AlertDescription, AlertTitle } from "@openstatus/ui"; 8 + 9 + interface Props { 10 + feature: string; 11 + } 12 + 13 + export function ProFeatureAlert({ feature }: Props) { 14 + const params = useParams<{ workspaceSlug: string }>(); 15 + return ( 16 + <Alert> 17 + <AlertTriangle className="h-4 w-4" /> 18 + <AlertTitle>{feature} are a Pro feature.</AlertTitle> 19 + <AlertDescription> 20 + If you want to use{" "} 21 + <span className="underline decoration-dotted">{feature}</span>, please 22 + upgrade to the Pro plan. Go to{" "} 23 + <Link 24 + href={`/app/${params.workspaceSlug}/settings/billing`} 25 + className="text-foreground inline-flex items-center font-medium underline underline-offset-4 hover:no-underline" 26 + > 27 + settings 28 + </Link> 29 + . 30 + </AlertDescription> 31 + </Alert> 32 + ); 33 + }
+11 -1
apps/web/src/components/data-table/status-page/columns.tsx
··· 1 1 "use client"; 2 2 3 3 import Image from "next/image"; 4 + import Link from "next/link"; 4 5 import type { ColumnDef } from "@tanstack/react-table"; 5 6 import * as z from "zod"; 6 7 ··· 18 19 accessorKey: "title", 19 20 header: "Title", 20 21 cell: ({ row }) => { 21 - return <span className="truncate">{row.getValue("title")}</span>; 22 + return ( 23 + <Link 24 + href={`./status-pages/${row.original.id}/edit`} 25 + className="group flex items-center gap-2" 26 + > 27 + <span className="max-w-[125px] truncate group-hover:underline"> 28 + {row.getValue("title")} 29 + </span> 30 + </Link> 31 + ); 22 32 }, 23 33 }, 24 34 {
+1 -1
apps/web/src/components/data-table/status-page/data-table-row-actions.tsx
··· 68 68 </Button> 69 69 </DropdownMenuTrigger> 70 70 <DropdownMenuContent align="end"> 71 - <Link href={`./status-pages/edit?id=${page.id}`}> 71 + <Link href={`./status-pages/${page.id}/edit`}> 72 72 <DropdownMenuItem>Edit</DropdownMenuItem> 73 73 </Link> 74 74 <Link
+11
packages/api/src/router/page.ts
··· 275 275 .returning() 276 276 .get(); 277 277 }), 278 + 279 + isPageLimitReached: protectedProcedure.query(async (opts) => { 280 + const pageLimit = allPlans[opts.ctx.workspace.plan].limits["status-pages"]; 281 + const pageNumbers = ( 282 + await opts.ctx.db.query.page.findMany({ 283 + where: eq(monitor.workspaceId, opts.ctx.workspace.id), 284 + }) 285 + ).length; 286 + 287 + return pageNumbers >= pageLimit; 288 + }), 278 289 });