import type { RouterOutputs } from "@openstatus/api"; import { allPlans } from "@openstatus/db/src/schema/plan/config"; import type { Addons } from "@openstatus/db/src/schema/plan/schema"; import { getAddonPriceConfig } from "@openstatus/db/src/schema/plan/utils"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from "@openstatus/ui/components/ui/alert-dialog"; import { Button } from "@openstatus/ui/components/ui/button"; import { ButtonGroup } from "@openstatus/ui/components/ui/button-group"; import { Input } from "@openstatus/ui/components/ui/input"; import { Label } from "@openstatus/ui/components/ui/label"; import { useCookieState } from "@openstatus/ui/hooks/use-cookie-state"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { isTRPCClientError } from "@trpc/client"; import { Check, MinusIcon, PlusIcon } from "lucide-react"; import { useEffect, useState, useTransition } from "react"; import { toast } from "sonner"; import { useTRPC } from "@/lib/trpc/client"; type Workspace = RouterOutputs["workspace"]["get"]; interface BillingAddonsProps { label: string; description: React.ReactNode; addon: keyof Addons; workspace: Workspace; } interface PriceConfig { value: number; currency: string; locale: string; } export function BillingAddons({ label, description, addon, workspace, }: BillingAddonsProps) { const [open, setOpen] = useState(false); const [isPending, startTransition] = useTransition(); const [currency] = useCookieState("x-currency", "USD"); const trpc = useTRPC(); const queryClient = useQueryClient(); const checkoutSessionMutation = useMutation( trpc.stripeRouter.addAddon.mutationOptions({ onSuccess: () => { queryClient.invalidateQueries({ queryKey: trpc.workspace.get.queryKey(), }); }, }), ); const plan = workspace.plan; const defaultLimit = allPlans[workspace.plan].limits[addon]; const workspaceLimit = workspace.limits[addon]; const defaultValue = typeof workspaceLimit === "number" && typeof defaultLimit === "number" ? // current value - default value to evaluate the difference workspaceLimit - defaultLimit : workspaceLimit; const [value, setValue] = useState(defaultValue); const price = getAddonPriceConfig(plan, addon, currency); // Reset value when modal opens useEffect(() => { if (open) { setValue(defaultValue); } }, [open, defaultValue]); function submitAction() { startTransition(async () => { try { // toggle the value if it's a boolean otherwise use the value const newValue = typeof value === "boolean" ? !value : value; const promise = checkoutSessionMutation.mutateAsync({ workspaceSlug: workspace.slug, feature: addon, value: newValue, }); toast.promise(promise, { loading: "Updating...", success: () => { setOpen(false); return "Billing information updated"; }, error: (error) => { if (isTRPCClientError(error)) { return error.message; } return "Failed to update"; }, }); await promise; } catch (error) { console.error(error); } }); } const hasAddon = typeof defaultValue === "number" ? defaultValue > 0 : defaultValue !== defaultLimit; const isQuantity = typeof value === "number"; return (
{description}
{formatPrice(price)} {isQuantity ? "/mo./each" : "/mo."} {hasAddon && !isQuantity ? ( ) : null} {hasAddon && isQuantity ? ( +{defaultValue} ) : null}
{label} {getDialogDescription(label, price, value, hasAddon)} {isQuantity && typeof value === "number" && typeof defaultLimit === "number" ? ( ) : null} Cancel { e.preventDefault(); submitAction(); }} disabled={ isPending || (typeof value === "number" && typeof defaultValue === "number" && value === defaultValue) } > {getButtonLabel(hasAddon, value, isPending)}
); } // NOTE: could move to lib/formatter.ts function formatPrice(price: PriceConfig | null) { if (!price) return "N/A"; return new Intl.NumberFormat(price.locale, { style: "currency", currency: price.currency, }).format(price.value); } function getButtonLabel( hasAddon: boolean, value: number | boolean, isPending = false, ) { if (isPending) return "Updating..."; const isBoolean = typeof value === "boolean"; const isQuantity = typeof value === "number"; if (isQuantity) return "Update"; if (isBoolean) { return hasAddon ? "Remove" : "Add"; } return null; } function getDialogDescription( label: string, price: PriceConfig | null, value: number | boolean, hasAddon: boolean, ) { const formattedPrice = formatPrice(price); const isBoolean = typeof value === "boolean"; const isQuantity = typeof value === "number"; const priceSuffix = isQuantity ? "/mo./each" : "/mo."; if (isBoolean) { if (hasAddon) { return `${label} will be removed from your subscription. You will save ${formattedPrice}${priceSuffix} on your next billing cycle.`; } return `${label} will be added to your subscription. You will be charged an additional ${formattedPrice}${priceSuffix} on your next billing cycle.`; } if (isQuantity) { return `${label} will be updated to ${value} on your next billing cycle. You will be charged ${formattedPrice}${priceSuffix} on your next billing cycle.`; } } function QuantityControl({ value, setValue, defaultLimit, }: { value: number; setValue: (value: number) => void; defaultLimit: number | boolean; }) { const handleChange = (e: React.ChangeEvent) => { const newValue = Number.parseInt(e.target.value); if (Number.isNaN(newValue)) { setValue(typeof defaultLimit === "number" ? defaultLimit : 0); } else { setValue( Math.max(typeof defaultLimit === "number" ? defaultLimit : 0, newValue), ); } }; return (
); }