"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("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 (
setInterval(v as BillingInterval)} > Monthly Yearly 2 months free {isTrialing ? "Upgrading ends your trial and charges your card today." : "A list to compare the different features by plan."} Features comparison {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 (

{plan.title}

{plan.description}

{isFreePlan ? (

Free

) : (

{new Intl.NumberFormat(price.locale, { style: "currency", currency: price.currency, }).format(price.value)} {interval === "yearly" ? "/year" : "/month"}

)}
); })}
{Object.entries(featureGroups).map( ([groupKey, { label, features }]) => ( {label} {features.map( ({ value, label: featureLabel, monthly, badge }) => (
{featureLabel}{" "} {badge ? ( {badge} ) : null}
{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 (
{isNumber ? new Intl.NumberFormat("us") .format(limitValue) .toString() : null} {isNumber ? " + " : ""} {new Intl.NumberFormat(price.locale, { style: "currency", currency: price.currency, }).format(price.value)} {isNumber ? getQuantitySuffix( value as keyof typeof plan.addons, interval, ) : getPeriodSuffix(interval)}
); } if (typeof limitValue === "boolean") { return limitValue ? ( ) : ( ‐ ); } 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 ( {renderContent()} {monthly ? "/mo." : ""} ); })}
), )}
), )}
); }