Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/openstatusHQ/openstatus. ๐ซ Status page with uptime monitoring & API monitoring as code ๐ซ openstatus.dev
bun drizzle-orm monitoring monitoring-as-code nextjs observability on-call open-source shadcn-ui status-page statuspage synthetic-monitoring tinybird turso uptime uptime-checker uptime-monitor
Something went wrong. Try again.
15 kB ยท 361 lines
TSX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362"use client";
import type { WorkspacePlan } from "@openstatus/db/src/schema";import type { Addons, BillingInterval,} from "@openstatus/db/src/schema/plan/schema";import { getAddonPackSize, getAddonPriceConfig, getPriceConfig,} from "@openstatus/db/src/schema/plan/utils";import { Check } from "@openstatus/icons";import { Badge } from "@openstatus/ui/components/ui/badge";import { Button } from "@openstatus/ui/components/ui/button";import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow,} from "@openstatus/ui/components/ui/table";import { Tabs, TabsList, TabsTrigger } from "@openstatus/ui/components/ui/tabs";import { useCookieState } from "@openstatus/ui/hooks/use-cookie-state";import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";import { isTRPCClientError } from "@trpc/client";import { useRouter } from "next/navigation";import { Fragment, useState, useTransition } from "react";import { toast } from "sonner";
import { config as featureGroups, plans } from "@/data/plans";import { useTRPC } from "@/lib/trpc/client";import { cn } from "@/lib/utils";
const BASE_URL = process.env.NODE_ENV === "production" ? "https://app.openstatus.dev" : "http://localhost:3001";
function getPeriodSuffix(interval: BillingInterval) { return interval === "yearly" ? "/yr." : "/mo.";}
function getQuantitySuffix(addon: keyof Addons, interval: BillingInterval) { const packSize = getAddonPackSize(addon); const period = getPeriodSuffix(interval); return packSize > 1 ? `${period}/${packSize}` : `${period}/each`;}
export function DataTable({ restrictTo }: { restrictTo?: WorkspacePlan[] }) { const [interval, setInterval] = useState<BillingInterval>("monthly"); const [currency] = useCookieState("x-currency", "USD"); const trpc = useTRPC(); const router = useRouter(); const queryClient = useQueryClient(); const [isPending, startTransition] = useTransition(); const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); const intervalQuery = useQuery( trpc.stripeRouter.getBillingInterval.queryOptions(), ); const currentInterval = intervalQuery.data;
const checkoutSessionMutation = useMutation( trpc.stripeRouter.getCheckoutSession.mutationOptions({ onSuccess: async (data) => { if (!data) return;
// An existing subscriber has the plan swapped on the subscription they // already have, so there is no checkout to redirect to โ only the // refreshed workspace to pick up. if (data.type === "updated") { await Promise.all([ queryClient.invalidateQueries({ queryKey: trpc.workspace.get.queryKey(), }), queryClient.invalidateQueries({ queryKey: trpc.stripeRouter.getBillingInterval.queryKey(), }), ]); toast.success("Your plan has been updated"); return; }
if (data.session.url) window.location.assign(data.session.url); }, }), );
const customerPortalMutation = useMutation( trpc.stripeRouter.getUserCustomerPortal.mutationOptions({ onSuccess: (url) => { if (!url) return; router.push(url); }, }), );
if (!workspace) return null;
const isTrialing = workspace.trialDaysLeft !== null; const filteredPlans = Object.values(plans).filter((plan) => restrictTo ? restrictTo.includes(plan.id) : true, );
return ( <div className="space-y-4"> <Tabs value={interval} onValueChange={(v) => setInterval(v as BillingInterval)} > <TabsList> <TabsTrigger value="monthly">Monthly</TabsTrigger> <TabsTrigger value="yearly"> Yearly <Badge className="ml-1.5 text-[10px]">2 months free</Badge> </TabsTrigger> </TabsList> </Tabs> <Table className="relative table-fixed"> <TableCaption> {isTrialing ? "Upgrading ends your trial and charges your card today." : "A list to compare the different features by plan."} </TableCaption> <TableHeader> <TableRow className="hover:bg-transparent"> <TableHead className="p-2 align-bottom"> Features comparison </TableHead> {filteredPlans.map(({ id, ...plan }) => { const isFreePlan = id === "free"; const isSamePlan = workspace.plan === id; // Hold the paid plan's button until the interval is known, so it // never flashes "Current Plan" on the wrong tab. const isIntervalLoading = isSamePlan && !isFreePlan && intervalQuery.isPending; // A resolved `null` (no subscription, legacy price) matches on the // plan alone. A failed fetch leaves both intervals selectable: // re-applying the price the customer already pays is a no-op. const isCurrentPlan = isSamePlan && (isFreePlan || (intervalQuery.isSuccess && (currentInterval === null || currentInterval === interval))); const isIntervalSwitch = isSamePlan && intervalQuery.isSuccess && currentInterval !== null && currentInterval !== interval; const price = getPriceConfig(id, currency, interval); return ( <TableHead key={id} className={cn( "text-foreground h-auto p-2 align-bottom", id === "starter" ? "bg-muted/30" : "", )} > <div className="flex h-full flex-col justify-between gap-1"> <div className="flex flex-1 flex-col gap-1"> <p className="font-cal text-lg">{plan.title}</p> <p className="text-muted-foreground text-xs font-normal text-wrap"> {plan.description} </p> </div> <div className="text-right"> {isFreePlan ? ( <p className="font-mono text-lg">Free</p> ) : ( <p> <span className="font-mono text-lg"> {new Intl.NumberFormat(price.locale, { style: "currency", currency: price.currency, }).format(price.value)} </span> <span className="text-muted-foreground text-sm"> {interval === "yearly" ? "/year" : "/month"} </span> </p> )} </div> <Button size="sm" type="button" variant={id === "starter" ? "default" : "outline"} onClick={() => { startTransition(async () => { try { if (id === "free") { await customerPortalMutation.mutateAsync({ workspaceSlug: workspace.slug, returnUrl: `${BASE_URL}/settings/billing`, }); return; } await checkoutSessionMutation.mutateAsync({ currency: currency || "USD", plan: id, interval, workspaceSlug: workspace.slug, successUrl: `${BASE_URL}/settings/billing?success=true`, cancelUrl: `${BASE_URL}/settings/billing`, }); } catch (error) { toast.error( isTRPCClientError(error) ? error.message : "Failed to update your plan", ); } }); }} disabled={isPending || isCurrentPlan || isIntervalLoading} > {isIntervalLoading ? "Loading..." : isCurrentPlan ? isTrialing ? "On Trial" : "Current Plan" : isPending ? "Choosing..." : isIntervalSwitch ? `Switch to ${interval}` : isTrialing && !isFreePlan ? "Upgrade now" : "Choose"} </Button> </div> </TableHead> ); })} </TableRow> </TableHeader> <TableBody> {Object.entries(featureGroups).map( ([groupKey, { label, features }]) => ( <Fragment key={groupKey}> <TableRow className="bg-muted/50"> <TableCell colSpan={filteredPlans.length + 1} className="font-medium" > {label} </TableCell> </TableRow> {features.map( ({ value, label: featureLabel, monthly, badge }) => ( <TableRow key={groupKey + value}> <TableCell> <div className="flex items-center gap-2 text-wrap"> {featureLabel}{" "} {badge ? ( <Badge variant="outline">{badge}</Badge> ) : null} </div> </TableCell> {filteredPlans.map((plan) => { const limitValue = plan.limits[value as keyof typeof plan.limits]; const isAddon = value in plan.addons;
function renderContent() { if (isAddon) { const price = getAddonPriceConfig( plan.id, value as keyof typeof plan.addons, currency, interval, ); if (!price) return null;
const isNumber = typeof limitValue === "number"; return ( <div> <span> {isNumber ? new Intl.NumberFormat("us") .format(limitValue) .toString() : null} </span> <span> <span className="text-muted-foreground"> {isNumber ? " + " : ""} </span> <span> {new Intl.NumberFormat(price.locale, { style: "currency", currency: price.currency, }).format(price.value)} {isNumber ? getQuantitySuffix( value as keyof typeof plan.addons, interval, ) : getPeriodSuffix(interval)} </span> </span> </div> ); } if (typeof limitValue === "boolean") { return limitValue ? ( <Check className="text-foreground h-4 w-4" /> ) : ( <span className="text-muted-foreground/50"> ‐ </span> ); } if (typeof limitValue === "number") { return new Intl.NumberFormat("us") .format(limitValue) .toString(); }
// TODO: create a format function for this in @data/plans if ( value === "regions" && Array.isArray(limitValue) ) { return limitValue?.length ?? 0; }
if ( Array.isArray(limitValue) && limitValue.length > 0 ) { return limitValue[0]; } return limitValue; }
return ( <TableCell key={plan.id + value} className={cn( "font-mono", plan.id === "starter" && "bg-muted/30", )} > {renderContent()} {monthly ? "/mo." : ""} </TableCell> ); })} </TableRow> ), )} </Fragment> ), )} </TableBody> </Table> </div> );}