From 245ce911996f50a91a58ca30dae40a5af74976cf Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 24 Mar 2026 16:07:06 +0700 Subject: [PATCH] feat: add bar charts for premium and AI governance add-on license usage (#23442) Implemented with the help of Cursor agents using Figma MCP Figma design: https://www.figma.com/design/klGTlHSPQwI4KBvAMdebrx/Customer-Usage-Controls-for-AI-Governance-Add-On?node-id=448-7658&m=dev Screenshot 2026-03-23 at 20 10 05 --- .../AIGovernanceLicensing.ts | 96 ++++++++++ ...overnanceUsersConsumptionChart.stories.tsx | 100 ++++++++++- .../AIGovernanceUsersConsumptionChart.tsx | 164 ++++-------------- .../LicensesSettingsPage/LicenseCard.tsx | 18 +- .../LicensesSettingsPage.tsx | 3 + .../LicensesSettingsPageView.stories.tsx | 83 +++++++-- .../LicensesSettingsPageView.tsx | 47 +++-- .../SeatUsageBarCard.stories.tsx | 64 +++++++ .../LicensesSettingsPage/SeatUsageBarCard.tsx | 84 +++++++++ 9 files changed, 478 insertions(+), 181 deletions(-) create mode 100644 site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/AIGovernanceLicensing.ts create mode 100644 site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/SeatUsageBarCard.stories.tsx create mode 100644 site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/SeatUsageBarCard.tsx diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/AIGovernanceLicensing.ts b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/AIGovernanceLicensing.ts new file mode 100644 index 0000000000..55cc1dc5fc --- /dev/null +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/AIGovernanceLicensing.ts @@ -0,0 +1,96 @@ +import type { GetLicensesResponse } from "api/api"; +import type { Feature } from "api/typesGenerated"; +import dayjs from "dayjs"; + +function isPremiumLicense(license: GetLicensesResponse): boolean { + return license.claims.feature_set?.toLowerCase() === "premium"; +} + +/** + * Matches the license card add-on section: Premium license with the + * ai_governance add-on in claims. + */ +export function licenseShowsAiGovernanceAddOn( + license: GetLicensesResponse, +): boolean { + return ( + isPremiumLicense(license) && + Boolean(license.claims.addons?.includes("ai_governance")) + ); +} + +export function isLicenseApplicableForAiGovernanceOverage( + license: GetLicensesResponse, + aiGovernanceUserFeature: Feature | undefined, +): boolean { + const isExpired = dayjs + .unix(license.claims.license_expires) + .isBefore(dayjs()); + const isNotYetValid = + license.claims.nbf !== undefined && + dayjs.unix(license.claims.nbf).isAfter(dayjs()); + const isAiGovernanceEntitlementInGracePeriod = + aiGovernanceUserFeature?.entitlement === "grace_period"; + + return ( + !isNotYetValid && (!isExpired || isAiGovernanceEntitlementInGracePeriod) + ); +} + +export function hasAiGovernanceAddOnLicense( + licenses: GetLicensesResponse[] | undefined, + aiGovernanceUserFeature: Feature | undefined, +): boolean { + return ( + licenses?.some( + (license) => + licenseShowsAiGovernanceAddOn(license) && + isLicenseApplicableForAiGovernanceOverage( + license, + aiGovernanceUserFeature, + ), + ) ?? false + ); +} + +/** + * Best-effort limit from license JWT claims when merged entitlements are not + * yet available. Uses the same per-license field as the add-on card. + */ +function aiGovernanceLimitFromLicenses( + licenses: GetLicensesResponse[], + aiGovernanceUserFeature: Feature | undefined, +): number | undefined { + const limits = licenses + .filter( + (license) => + licenseShowsAiGovernanceAddOn(license) && + isLicenseApplicableForAiGovernanceOverage( + license, + aiGovernanceUserFeature, + ), + ) + .map((license) => license.claims.features?.ai_governance_user_limit) + .filter((limit): limit is number => limit !== undefined); + return limits.length > 0 ? Math.max(...limits) : undefined; +} + +/** + * Resolves the displayed AI governance user limit for summary charts. + * + * When the feature is not entitled yet, JWT claims can still carry the + * purchased add-on seat limit while entitlements may report limit 0. + */ +export function effectiveAiGovernanceLimitForUsageCard( + aiGovernanceUserFeature: Feature | undefined, + licenses: GetLicensesResponse[] | undefined, +): number | undefined { + const limitFromClaims = licenses + ? aiGovernanceLimitFromLicenses(licenses, aiGovernanceUserFeature) + : undefined; + const limitFromEntitlements = aiGovernanceUserFeature?.limit; + + return aiGovernanceUserFeature?.enabled === true + ? (limitFromEntitlements ?? limitFromClaims) + : (limitFromClaims ?? limitFromEntitlements); +} diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/AIGovernanceUsersConsumptionChart.stories.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/AIGovernanceUsersConsumptionChart.stories.tsx index 70e12fda6a..7f02300f70 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/AIGovernanceUsersConsumptionChart.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/AIGovernanceUsersConsumptionChart.stories.tsx @@ -1,7 +1,59 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { GetLicensesResponse } from "api/api"; import { expect, within } from "storybook/test"; import { AIGovernanceUsersConsumption } from "./AIGovernanceUsersConsumptionChart"; +const licenseWithAiGovernanceAddOn: GetLicensesResponse = { + id: 42, + uploaded_at: "1660104000", + expires_at: "3420244800", + uuid: "license-ai-gov-addon", + claims: { + trial: false, + all_features: true, + feature_set: "premium", + version: 1, + addons: ["ai_governance"], + features: { ai_governance_user_limit: 750 }, + license_expires: 3420244800, + nbf: 1660104000, + }, +}; + +const higherApplicableAiGovernanceLicense: GetLicensesResponse = { + id: 43, + uploaded_at: "1660104000", + expires_at: "3420244800", + uuid: "license-ai-gov-addon-higher", + claims: { + trial: false, + all_features: true, + feature_set: "premium", + version: 1, + addons: ["ai_governance"], + features: { ai_governance_user_limit: 900 }, + license_expires: 3420244800, + nbf: 1660104000, + }, +}; + +const nonApplicableAiGovernanceLicense: GetLicensesResponse = { + id: 44, + uploaded_at: "1660104000", + expires_at: "3420244800", + uuid: "license-ai-gov-addon-non-applicable", + claims: { + trial: false, + all_features: true, + feature_set: "enterprise", + version: 1, + addons: ["ai_governance"], + features: { ai_governance_user_limit: 1200 }, + license_expires: 3420244800, + nbf: 1660104000, + }, +}; + const meta: Meta = { title: "pages/DeploymentSettingsPage/LicensesSettingsPage/AIGovernanceUsersConsumptionChart", @@ -11,8 +63,9 @@ const meta: Meta = { enabled: true, entitlement: "entitled", limit: 1000, - actual: 750, + actual: 512, }, + licenses: [], }, }; @@ -33,10 +86,7 @@ export const Exceeded: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); await expect(canvas.getByText("1,200")).toBeInTheDocument(); - await expect( - canvas.getByText(/of 1,000 Users Entitled/i), - ).toBeInTheDocument(); - await expect(canvas.getByText("Add-on exceeded")).toBeInTheDocument(); + await expect(canvas.getByText("1,000")).toBeInTheDocument(); }, }; @@ -46,12 +96,52 @@ export const Disabled: Story = { enabled: false, entitlement: "not_entitled", }, + licenses: [], }, }; export const NoFeature: Story = { args: { aiGovernanceUserFeature: undefined, + licenses: [], + }, +}; + +/** Entitlements not enabled, but a Premium license lists the add-on and limit in JWT claims. */ +export const UsageBarFromLicenseClaims: Story = { + args: { + aiGovernanceUserFeature: { + enabled: false, + entitlement: "not_entitled", + }, + licenses: [licenseWithAiGovernanceAddOn], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("750")).toBeInTheDocument(); + await expect( + canvas.getByRole("heading", { name: "AI governance add-on usage" }), + ).toBeInTheDocument(); + }, +}; + +/** Picks the highest applicable limit when multiple licenses are present. */ +export const UsageBarUsesHighestApplicableLicenseLimit: Story = { + args: { + aiGovernanceUserFeature: { + enabled: false, + entitlement: "not_entitled", + }, + licenses: [ + licenseWithAiGovernanceAddOn, + higherApplicableAiGovernanceLicense, + nonApplicableAiGovernanceLicense, + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("900")).toBeInTheDocument(); + await expect(canvas.queryByText("1,200")).not.toBeInTheDocument(); }, }; diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/AIGovernanceUsersConsumptionChart.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/AIGovernanceUsersConsumptionChart.tsx index 68c3cd704f..1ed6897b65 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/AIGovernanceUsersConsumptionChart.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/AIGovernanceUsersConsumptionChart.tsx @@ -1,31 +1,40 @@ +import type { GetLicensesResponse } from "api/api"; import type { Feature } from "api/typesGenerated"; -import { ErrorAlert } from "components/Alert/ErrorAlert"; -import { Button } from "components/Button/Button"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "components/Collapsible/Collapsible"; import { Link } from "components/Link/Link"; -import { ChevronRightIcon } from "lucide-react"; import type { FC } from "react"; -import { cn } from "utils/cn"; -import { docs } from "utils/docs"; +import { + effectiveAiGovernanceLimitForUsageCard, + hasAiGovernanceAddOnLicense, +} from "./AIGovernanceLicensing"; +import { SeatUsageBarCard } from "./SeatUsageBarCard"; interface AIGovernanceUsersConsumptionProps { aiGovernanceUserFeature?: Feature; + licenses?: GetLicensesResponse[]; } export const AIGovernanceUsersConsumption: FC< AIGovernanceUsersConsumptionProps -> = ({ aiGovernanceUserFeature }) => { - // If no feature is provided or it's disabled, show disabled state - if (!aiGovernanceUserFeature?.enabled) { +> = ({ aiGovernanceUserFeature, licenses }) => { + const hasAddOnLicense = hasAiGovernanceAddOnLicense( + licenses, + aiGovernanceUserFeature, + ); + const effectiveLimit = effectiveAiGovernanceLimitForUsageCard( + aiGovernanceUserFeature, + licenses, + ); + + const showUsageBar = + aiGovernanceUserFeature?.enabled === true || + (hasAddOnLicense && effectiveLimit !== undefined); + + if (!showUsageBar) { return ( -
-
-
- Users with AI Governance Add-On +
+
+
+ AI governance add-on usage AI Governance is not included in your current license. Contact{" "} sales to upgrade your @@ -37,122 +46,11 @@ export const AIGovernanceUsersConsumption: FC< ); } - const limit = aiGovernanceUserFeature.limit; - - if (limit === undefined || limit < 0) { - return ; - } - - const actual = aiGovernanceUserFeature.actual; - const isExceeded = actual !== undefined && actual > limit; - return ( -
-
- -
-

- Users with AI Governance Add-On -

- - - - -
- - -

- Users using AI features like{" "} - - AI Bridge - - ,{" "} - - Boundary - - , or{" "} - - Tasks - - . -

-
-
-
- -
-
- {actual !== undefined ? ( - <> -
-
- {actual.toLocaleString()} -
-
- of {limit.toLocaleString()} Users Entitled -
-
-
- {isExceeded ? "Add-on exceeded" : "Active"} -
- - ) : ( - <> -
-
- {limit.toLocaleString()} -
-
- Users Entitled -
-
-
- Additional analytics and measurements coming soon -
- - )} -
-
-
+ ); }; diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx index b99c6a66ad..146f76e778 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx @@ -18,6 +18,10 @@ import { ChevronDownIcon, EllipsisVerticalIcon, TrashIcon } from "lucide-react"; import { type FC, useState } from "react"; import { cn } from "utils/cn"; import { AIGovernanceAddOnCard } from "./AIGovernanceAddOnCard"; +import { + isLicenseApplicableForAiGovernanceOverage, + licenseShowsAiGovernanceAddOn, +} from "./AIGovernanceLicensing"; type LicenseCardProps = { license: GetLicensesResponse; @@ -61,21 +65,19 @@ export const LicenseCard: FC = ({ ? "Premium" : "Enterprise"; - const hasExplicitAiGovernanceAddOn = (license.claims.addons ?? []).includes( - "ai_governance", - ); - const isAiGovernanceEntitlementInGracePeriod = - aiGovernanceUserFeature?.entitlement === "grace_period"; + const hasExplicitAiGovernanceAddOn = licenseShowsAiGovernanceAddOn(license); // Overage/display checks only apply to licenses that are currently effective. - const isLicenseApplicableForAiGovernanceOverage = - !isNotYetValid && (!isExpired || isAiGovernanceEntitlementInGracePeriod); + const isLicenseApplicable = isLicenseApplicableForAiGovernanceOverage( + license, + aiGovernanceUserFeature, + ); // A license "wins" when its AI governance limit matches the merged limit. const isWinningAiGovernanceLicense = aiGovernanceMergedLimit !== undefined && aiGovernanceLimit > 0 && aiGovernanceLimit === aiGovernanceMergedLimit; const canUseAiGovernanceUsageForThisLicense = - isLicenseApplicableForAiGovernanceOverage && + isLicenseApplicable && hasExplicitAiGovernanceAddOn && isWinningAiGovernanceLicense; // Show the add-on as exceeded only for the winning, active add-on license. diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPage.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPage.tsx index 5469a73d0d..6095d75fae 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPage.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPage.tsx @@ -82,6 +82,9 @@ const LicensesSettingsPage: FC = () => { showConfetti={confettiOn} isLoading={isLoading} isRefreshing={refreshEntitlementsMutation.isPending} + hasUserLimitEntitlementData={ + entitlementsQuery.data?.features.user_limit !== undefined + } userLimitActual={entitlementsQuery.data?.features.user_limit?.actual} userLimitLimit={entitlementsQuery.data?.features.user_limit?.limit} licenses={licenses} diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPageView.stories.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPageView.stories.tsx index 87f1405557..4f7922d2a1 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPageView.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPageView.stories.tsx @@ -1,28 +1,75 @@ import { chromatic } from "testHelpers/chromatic"; import { MockLicenseResponse } from "testHelpers/entities"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { Feature } from "api/typesGenerated"; +import { expect, fn, within } from "storybook/test"; import LicensesSettingsPageView from "./LicensesSettingsPageView"; -export default { +const meta: Meta = { title: "pages/DeploymentSettingsPage/LicensesSettingsPageView", parameters: { chromatic }, component: LicensesSettingsPageView, -}; - -const defaultArgs = { - showConfetti: false, - isLoading: false, - userLimitActual: 1, - userLimitLimit: 10, - licenses: MockLicenseResponse, -}; - -export const Default = { - args: defaultArgs, -}; - -export const Empty = { args: { - ...defaultArgs, - licenses: null, + showConfetti: false, + isLoading: false, + hasUserLimitEntitlementData: true, + userLimitActual: 1, + userLimitLimit: 10, + licenses: MockLicenseResponse, + isRemovingLicense: false, + isRefreshing: false, + removeLicense: fn(), + refreshEntitlements: fn(), + activeUsers: [{ date: "2024-01-01", count: 1 }], + managedAgentFeature: { + enabled: false, + entitlement: "not_entitled", + } satisfies Feature, + aiGovernanceUserFeature: { + enabled: false, + entitlement: "not_entitled", + } satisfies Feature, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Empty: Story = { + args: { + licenses: [], + }, +}; + +/** Premium + AI governance usage bars; AI governance shows `SeatUsageBarCard` (not the not-entitled placeholder). */ +export const ActiveAIGovernanceAddOnUsage: Story = { + args: { + userLimitActual: 1923, + userLimitLimit: 2500, + activeUsers: [ + { date: "2024-01-01", count: 100 }, + { date: "2024-02-01", count: 120 }, + ], + aiGovernanceUserFeature: { + enabled: true, + entitlement: "entitled", + limit: 1000, + actual: 512, + } satisfies Feature, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByRole("heading", { name: "Seat usage" }), + ).toBeInTheDocument(); + await expect(canvas.getByText("1,923")).toBeInTheDocument(); + await expect(canvas.getByText("2,500")).toBeInTheDocument(); + await expect( + canvas.getByRole("heading", { name: "AI governance add-on usage" }), + ).toBeInTheDocument(); + await expect(canvas.getByText("512")).toBeInTheDocument(); + await expect(canvas.getByText("1,000")).toBeInTheDocument(); }, }; diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPageView.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPageView.tsx index 6c7d3401f3..e9cf6ea96b 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPageView.tsx @@ -20,15 +20,17 @@ import { useWindowSize } from "hooks/useWindowSize"; import { PlusIcon, RotateCwIcon } from "lucide-react"; import type { FC } from "react"; import Confetti from "react-confetti"; -import { Link } from "react-router"; +import { Link as RouterLink } from "react-router"; import { AIGovernanceUsersConsumption } from "./AIGovernanceUsersConsumptionChart"; import { LicenseCard } from "./LicenseCard"; import { LicenseSeatConsumptionChart } from "./LicenseSeatConsumptionChart"; import { ManagedAgentsConsumption } from "./ManagedAgentsConsumption"; +import { SeatUsageBarCard } from "./SeatUsageBarCard"; type Props = { showConfetti: boolean; isLoading: boolean; + hasUserLimitEntitlementData: boolean; userLimitActual?: number; userLimitLimit?: number; licenses?: GetLicensesResponse[]; @@ -44,6 +46,7 @@ type Props = { const LicensesSettingsPageView: FC = ({ showConfetti, isLoading, + hasUserLimitEntitlementData, userLimitActual, userLimitLimit, licenses, @@ -82,10 +85,10 @@ const LicensesSettingsPageView: FC = ({ @@ -156,25 +159,35 @@ const LicensesSettingsPageView: FC = ({
)} - {licenses && licenses.length > 0 && ( - ({ - date: i.date, - users: i.count, - limit: 80, - }))} - /> - )} - {licenses && licenses.length > 0 && ( <> + ({ + date: i.date, + users: i.count, + limit: 80, + }))} + /> + +
+ {hasUserLimitEntitlementData && ( + + )} + +
+ - )}
diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/SeatUsageBarCard.stories.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/SeatUsageBarCard.stories.tsx new file mode 100644 index 0000000000..1584c84d29 --- /dev/null +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/SeatUsageBarCard.stories.tsx @@ -0,0 +1,64 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, within } from "storybook/test"; +import { SeatUsageBarCard } from "./SeatUsageBarCard"; + +const meta: Meta = { + title: "pages/DeploymentSettingsPage/LicensesSettingsPage/SeatUsageBarCard", + component: SeatUsageBarCard, + args: { + title: "Seat usage", + actual: 1923, + limit: 2500, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const NearLimit: Story = { + args: { + actual: 2400, + limit: 2500, + }, +}; + +export const OverLimit: Story = { + args: { + actual: 2600, + limit: 2500, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("2,600")).toBeInTheDocument(); + await expect(canvas.getByText("2,500")).toBeInTheDocument(); + }, +}; + +export const MissingActual: Story = { + args: { + actual: undefined, + limit: 1000, + }, +}; + +export const ErrorInvalidLimit: Story = { + args: { + actual: 100, + limit: undefined, + }, +}; + +export const Unlimited: Story = { + args: { + actual: 1923, + limit: undefined, + allowUnlimited: true, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("1,923")).toBeInTheDocument(); + await expect(canvas.getByText("Unlimited")).toBeInTheDocument(); + }, +}; diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/SeatUsageBarCard.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/SeatUsageBarCard.tsx new file mode 100644 index 0000000000..0349e72498 --- /dev/null +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/SeatUsageBarCard.tsx @@ -0,0 +1,84 @@ +import { ErrorAlert } from "components/Alert/ErrorAlert"; +import type { FC } from "react"; +import { cn } from "utils/cn"; + +type SeatUsageBarCardProps = { + title: string; + actual: number | undefined; + limit: number | undefined; + allowUnlimited?: boolean; +}; + +export const SeatUsageBarCard: FC = ({ + title, + actual, + limit, + allowUnlimited = false, +}) => { + const isUnlimited = allowUnlimited && limit === undefined; + + if (!isUnlimited && (limit === undefined || limit < 0)) { + return ( +
+
+ +
+
+ ); + } + + const meteredLimit = limit ?? 0; + const activeNum = actual ?? 0; + const isExceeded = + !isUnlimited && actual !== undefined && actual > meteredLimit; + const usagePercentage = isUnlimited + ? 100 + : meteredLimit > 0 + ? Math.min((activeNum / meteredLimit) * 100, 100) + : 0; + + const activeLabel = + actual === undefined ? "—" : activeNum.toLocaleString("en-US"); + const limitLabel = isUnlimited + ? "Unlimited" + : meteredLimit.toLocaleString("en-US"); + + return ( +
+
+
+

{title}

+ + +
+
+ ); +};