Openstatus www.openstatus.dev

feat: progress bar (#596)

authored by

Maximilian Kaske and committed by
GitHub
3d258a59 39ce0106

+142 -41
+5
apps/web/src/app/(redirect)/docs/page.tsx
··· 1 + import { redirect } from "next/navigation"; 2 + 3 + export default function DiscordRedirect() { 4 + return redirect("https://docs.openstatus.dev"); 5 + }
+1 -1
apps/web/src/app/app/[workspaceSlug]/(dashboard)/settings/billing/_components/customer-portal-button.tsx
··· 30 30 return ( 31 31 <Button 32 32 size="sm" 33 - variant="outline" 33 + variant="secondary" 34 34 onClick={getUserCustomerPortal} 35 35 disabled={isPending} 36 36 >
+11 -30
apps/web/src/app/app/[workspaceSlug]/(dashboard)/settings/billing/_components/plan.tsx
··· 4 4 import { useRouter } from "next/navigation"; 5 5 6 6 import type { Workspace, WorkspacePlan } from "@openstatus/db/src/schema"; 7 - import { Button } from "@openstatus/ui"; 8 7 9 - import { LoadingAnimation } from "@/components/loading-animation"; 10 8 import { PricingTable } from "@/components/marketing/pricing/pricing-table"; 11 9 import { getStripe } from "@/lib/stripe/client"; 12 10 import { api } from "@/trpc/client"; ··· 42 40 }; 43 41 44 42 return ( 45 - <div className="grid gap-4"> 46 - <div className="grid gap-6"> 47 - <div> 48 - <Button 49 - onClick={getUserCustomerPortal} 50 - variant="outline" 51 - disabled={isPending} 52 - > 53 - {isPending ? ( 54 - <LoadingAnimation variant="inverse" /> 55 - ) : ( 56 - "Customer Portal" 57 - )} 58 - </Button> 59 - </div> 60 - <PricingTable 61 - currentPlan={workspace.plan} 62 - isLoading={isPending} 63 - events={{ 64 - // REMINDER: redirecting to customer portal as a fallback because the free plan has no price 65 - free: getUserCustomerPortal, 66 - starter: () => getCheckoutSession("starter"), 67 - pro: () => getCheckoutSession("pro"), 68 - team: () => getCheckoutSession("team"), 69 - }} 70 - /> 71 - </div> 72 - </div> 43 + <PricingTable 44 + currentPlan={workspace.plan} 45 + isLoading={isPending} 46 + events={{ 47 + // REMINDER: redirecting to customer portal as a fallback because the free plan has no price 48 + free: getUserCustomerPortal, 49 + starter: () => getCheckoutSession("starter"), 50 + pro: () => getCheckoutSession("pro"), 51 + team: () => getCheckoutSession("team"), 52 + }} 53 + /> 73 54 ); 74 55 };
+31 -4
apps/web/src/app/app/[workspaceSlug]/(dashboard)/settings/billing/page.tsx
··· 1 + import { getLimits } from "@openstatus/plans"; 2 + import { Progress, Separator } from "@openstatus/ui"; 3 + 1 4 import { api } from "@/trpc/server"; 2 5 import { SettingsPlan } from "./_components/plan"; 3 6 4 7 export default async function BillingPage() { 5 8 const workspace = await api.workspace.getWorkspace.query(); 9 + const currentNumbers = await api.workspace.getCurrentWorkspaceNumbers.query(); 10 + 11 + const limits = getLimits(workspace.plan); 12 + 6 13 return ( 7 - <div className="grid gap-3"> 8 - <h3 className="text-lg font-medium"> 9 - <span className="capitalize">{workspace.plan}</span> plan 10 - </h3> 14 + <div className="grid gap-4"> 15 + <div className="flex items-center justify-between gap-3"> 16 + <h3 className="text-lg font-medium"> 17 + <span className="capitalize">{workspace.plan}</span> plan 18 + </h3> 19 + <CustomerPortalButton workspaceSlug={workspace.slug} /> 20 + </div> 21 + <div className="grid max-w-lg gap-3"> 22 + {Object.entries(currentNumbers).map(([key, value]) => { 23 + const limit = limits[key as keyof typeof currentNumbers]; 24 + return ( 25 + <div key={key}> 26 + <div className="text-muted-foreground mb-1 flex items-center justify-between"> 27 + <p className="text-sm capitalize">{key.replace("-", " ")}</p> 28 + <p className="text-xs"> 29 + <span className="text-foreground">{value}</span> / {limit} 30 + </p> 31 + </div> 32 + <Progress value={(value / limit) * 100} /> 33 + </div> 34 + ); 35 + })} 36 + </div> 37 + <Separator className="my-4" /> 11 38 <SettingsPlan workspace={workspace} /> 12 39 </div> 13 40 );
apps/web/src/app/discord/page.tsx apps/web/src/app/(redirect)/discord/page.tsx
apps/web/src/app/github/page.tsx apps/web/src/app/(redirect)/github/page.tsx
+1 -1
apps/web/src/components/layout/app-header.tsx
··· 34 34 <ul className="flex gap-2"> 35 35 <li className="w-full"> 36 36 <Button variant="link" asChild> 37 - <Link href="https://docs.openstatus.dev" target="_blank"> 37 + <Link href="/docs" target="_blank"> 38 38 Docs 39 39 <ArrowUpRight className="ml-1 h-4 w-4 flex-shrink-0" /> 40 40 </Link>
+1 -1
apps/web/src/components/marketing/pricing/pricing-plan-radio.tsx
··· 30 30 key === "team" && "bg-muted/50", 31 31 )} 32 32 > 33 - <span className="capitalize">{key}</span> 33 + <span className="text-sm capitalize">{key}</span> 34 34 <span className="text-muted-foreground mt-1 text-xs font-light"> 35 35 {allPlans[key].price}€/month 36 36 </span>
+29 -1
packages/api/src/router/workspace.ts
··· 2 2 import { generateSlug } from "random-word-slugs"; 3 3 import { z } from "zod"; 4 4 5 - import { and, eq } from "@openstatus/db"; 5 + import { and, eq, sql } from "@openstatus/db"; 6 6 import { 7 + monitor, 8 + notification, 9 + page, 7 10 selectWorkspaceSchema, 8 11 user, 9 12 usersToWorkspaces, 10 13 workspace, 11 14 workspacePlanSchema, 12 15 } from "@openstatus/db/src/schema"; 16 + import { Limits } from "@openstatus/plans"; 13 17 14 18 import { createTRPCRouter, protectedProcedure } from "../trpc"; 15 19 ··· 154 158 .returning() 155 159 .get(); 156 160 }), 161 + 162 + getCurrentWorkspaceNumbers: protectedProcedure.query(async (opts) => { 163 + const currentNumbers = await opts.ctx.db.transaction(async (tx) => { 164 + const notifications = await tx 165 + .select({ count: sql<number>`count(*)` }) 166 + .from(notification) 167 + .where(eq(notification.workspaceId, opts.ctx.workspace.id)); 168 + const monitors = await tx 169 + .select({ count: sql<number>`count(*)` }) 170 + .from(monitor) 171 + .where(eq(monitor.workspaceId, opts.ctx.workspace.id)); 172 + const pages = await tx 173 + .select({ count: sql<number>`count(*)` }) 174 + .from(page) 175 + .where(eq(page.workspaceId, opts.ctx.workspace.id)); 176 + return { 177 + "notification-channels": notifications?.[0].count || 0, 178 + monitors: monitors?.[0].count || 0, 179 + "status-pages": pages?.[0].count || 0, 180 + } satisfies Partial<Limits>; 181 + }); 182 + 183 + return currentNumbers; 184 + }), 157 185 });
+4 -3
packages/plans/src/index.ts
··· 1 - export { allPlans } from "./config"; 2 - export { getLimit } from "./utils"; 3 - export { pricingTableConfig } from "./pricing-table"; 1 + export * from "./config"; 2 + export * from "./utils"; 3 + export * from "./pricing-table"; 4 + export * from "./types"; 4 5 5 6 export { workspacePlans as plans } from "@openstatus/db/src/schema"; 6 7 export type { WorkspacePlan } from "@openstatus/db/src/schema";
+4
packages/plans/src/utils.ts
··· 10 10 ) { 11 11 return allPlans[plan].limits[limit]; 12 12 } 13 + 14 + export function getLimits(plan: WorkspacePlan) { 15 + return allPlans[plan].limits; 16 + }
+1
packages/ui/package.json
··· 29 29 "@radix-ui/react-hover-card": "1.0.7", 30 30 "@radix-ui/react-label": "2.0.2", 31 31 "@radix-ui/react-popover": "1.0.7", 32 + "@radix-ui/react-progress": "^1.0.3", 32 33 "@radix-ui/react-radio-group": "1.1.3", 33 34 "@radix-ui/react-select": "2.0.0", 34 35 "@radix-ui/react-separator": "1.0.3",
+28
packages/ui/src/components/progress.tsx
··· 1 + "use client"; 2 + 3 + import * as React from "react"; 4 + import * as ProgressPrimitive from "@radix-ui/react-progress"; 5 + 6 + import { cn } from "@/lib/utils"; 7 + 8 + const Progress = React.forwardRef< 9 + React.ElementRef<typeof ProgressPrimitive.Root>, 10 + React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> 11 + >(({ className, value, ...props }, ref) => ( 12 + <ProgressPrimitive.Root 13 + ref={ref} 14 + className={cn( 15 + "bg-primary/20 relative h-2 w-full overflow-hidden rounded-full", 16 + className, 17 + )} 18 + {...props} 19 + > 20 + <ProgressPrimitive.Indicator 21 + className="bg-primary h-full w-full flex-1 transition-all" 22 + style={{ transform: `translateX(-${100 - (value || 0)}%)` }} 23 + /> 24 + </ProgressPrimitive.Root> 25 + )); 26 + Progress.displayName = ProgressPrimitive.Root.displayName; 27 + 28 + export { Progress };
+1
packages/ui/src/index.tsx
··· 19 19 export * from "./components/input"; 20 20 export * from "./components/label"; 21 21 export * from "./components/popover"; 22 + export * from "./components/progress"; 22 23 export * from "./components/radio-group"; 23 24 export * from "./components/select"; 24 25 export * from "./components/separator";
+25
pnpm-lock.yaml
··· 813 813 '@radix-ui/react-popover': 814 814 specifier: 1.0.7 815 815 version: 1.0.7(@types/react-dom@18.2.8)(@types/react@18.2.24)(react-dom@18.2.0)(react@18.2.0) 816 + '@radix-ui/react-progress': 817 + specifier: ^1.0.3 818 + version: 1.0.3(@types/react-dom@18.2.8)(@types/react@18.2.24)(react-dom@18.2.0)(react@18.2.0) 816 819 '@radix-ui/react-radio-group': 817 820 specifier: 1.1.3 818 821 version: 1.1.3(@types/react-dom@18.2.8)(@types/react@18.2.24)(react-dom@18.2.0)(react@18.2.0) ··· 3852 3855 dependencies: 3853 3856 '@babel/runtime': 7.23.2 3854 3857 '@radix-ui/react-slot': 1.0.2(@types/react@18.2.24)(react@18.2.0) 3858 + '@types/react': 18.2.24 3859 + '@types/react-dom': 18.2.8 3860 + react: 18.2.0 3861 + react-dom: 18.2.0(react@18.2.0) 3862 + dev: false 3863 + 3864 + /@radix-ui/react-progress@1.0.3(@types/react-dom@18.2.8)(@types/react@18.2.24)(react-dom@18.2.0)(react@18.2.0): 3865 + resolution: {integrity: sha512-5G6Om/tYSxjSeEdrb1VfKkfZfn/1IlPWd731h2RfPuSbIfNUgfqAwbKfJCg/PP6nuUCTrYzalwHSpSinoWoCag==} 3866 + peerDependencies: 3867 + '@types/react': '*' 3868 + '@types/react-dom': '*' 3869 + react: ^16.8 || ^17.0 || ^18.0 3870 + react-dom: ^16.8 || ^17.0 || ^18.0 3871 + peerDependenciesMeta: 3872 + '@types/react': 3873 + optional: true 3874 + '@types/react-dom': 3875 + optional: true 3876 + dependencies: 3877 + '@babel/runtime': 7.23.2 3878 + '@radix-ui/react-context': 1.0.1(@types/react@18.2.24)(react@18.2.0) 3879 + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.8)(@types/react@18.2.24)(react-dom@18.2.0)(react@18.2.0) 3855 3880 '@types/react': 18.2.24 3856 3881 '@types/react-dom': 18.2.8 3857 3882 react: 18.2.0