onChange({ ...value, plan: 'team' }) : undefined
+ }
+ />
{footer} {outgrewAll ? 'This workspace goes past Team.' : 'More users, single sign-on or SCIM?'}{' '}
;
+
+interface Candidate {
+ id: string;
+ name: string;
+ /** Tool calls in the last 30 days, when known. */
+ calls: number | null;
+}
+
+const UPGRADE_UTM = { utm_source: 'plan-swap', utm_medium: 'dialog', utm_campaign: 'plan-limit' };
+
+/** "Paused: plan limit": the item was paused by the last automatic downgrade. */
+export function PausedByPlanBadge({ className }: { className?: string }) {
+ const t = useTranslations('planSwap');
+ return (
+
+ {t('badge')}
+
+ );
+}
+
+/**
+ * Upgrade from the plan in use: Starter goes to Team through the billing
+ * portal; past Team (or on a trial, which already has Team's limits)
+ * Enterprise is a quote.
+ */
+function UpgradeLink({ plan, className }: { plan: string | null | undefined; className?: string }) {
+ const t = useTranslations('planSwap');
+ const managePlan = useManagePlan(UPGRADE_UTM);
+ if (plan === 'starter') {
+ return (
+
+ {t('upgradeTeam')}
+
+ );
+ }
+ return (
+
+ {t('askEnterprise')}
+
+ );
+}
+
+/**
+ * The swap at the plan's cap: "Your plan includes 5 active connectors. Pause
+ * one to activate this one", with the active ones (most used first when the
+ * plan fit gives their calls), "Pause and activate", and the upgrade.
+ *
+ * Without `target` it is the create case: pause one so a new one can be added.
+ * `activate` replaces the default activation (the connector page saves its
+ * whole form instead). Cloud only; nothing here runs on self-hosted.
+ */
+export function PlanSwapDialog({
+ open,
+ onOpenChange,
+ axis,
+ target,
+ max: maxProp,
+ activate,
+ onDone,
+}: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ axis: SwapAxis;
+ target?: { id: string; name: string } | null;
+ max?: number | null;
+ activate?: () => Promise;
+ onDone?: () => void;
+}) {
+ const t = useTranslations('planSwap');
+ const { token } = useAuth();
+ const [candidates, setCandidates] = useState(null);
+ const [ranked, setRanked] = useState(false);
+ const [plan, setPlan] = useState(null);
+ const [max, setMax] = useState(maxProp ?? null);
+ const [chosen, setChosen] = useState(null);
+ const [working, setWorking] = useState(false);
+ const [error, setError] = useState('');
+ const [done, setDone] = useState('');
+
+ useEffect(() => {
+ if (!open || !token) return;
+ let live = true;
+ (async () => {
+ const usage = await license.getUsage(token).catch(() => null);
+ const fitPlan = fitPlanOf(usage?.plan);
+ const fit = fitPlan
+ ? await license
+ .getPlanFit(token, fitPlan)
+ .then((r) => r?.plans?.[fitPlan] ?? null)
+ .catch(() => null)
+ : null;
+ let list: Candidate[] = [];
+ let haveCalls = false;
+ if (axis === 'mcpServers') {
+ const servers = await mcpServersApi.list(token).catch(() => []);
+ list = servers
+ .filter((s) => s.isActive && s.id !== target?.id)
+ .map((s) => ({ id: s.id, name: s.name, calls: typeof s.usage?.calls30d === 'number' ? s.usage.calls30d : null }));
+ haveCalls = list.some((c) => c.calls != null);
+ } else {
+ const all = await connectorsApi.list(token).catch(() => []);
+ const ranking = axis === 'fileSearchConnectors' ? fit?.fileSearch.connectors : fit?.connectors;
+ const calls = new Map();
+ for (const item of [...(ranking?.keep ?? []), ...(ranking?.pause ?? [])]) calls.set(item.id, item.calls30d);
+ haveCalls = calls.size > 0;
+ list = all
+ .filter(
+ (c) =>
+ c.isActive &&
+ c.id !== target?.id &&
+ (axis === 'fileSearchConnectors' ? c.type === 'FILE_SEARCH' : c.type !== 'FILE_SEARCH'),
+ )
+ .map((c) => ({ id: c.id, name: c.name, calls: calls.get(c.id) ?? (haveCalls ? 0 : null) }));
+ }
+ list.sort((a, b) => (b.calls ?? 0) - (a.calls ?? 0) || a.name.localeCompare(b.name));
+ if (!live) return;
+ setError('');
+ setDone('');
+ setPlan(usage?.plan ?? null);
+ if (maxProp == null) {
+ const fromUsage =
+ axis === 'mcpServers'
+ ? usage?.mcpServers.max
+ : axis === 'connectors'
+ ? usage?.connectors.max
+ : fit?.fileSearch.connectors.max;
+ setMax(fromUsage ?? null);
+ } else {
+ setMax(maxProp);
+ }
+ setRanked(haveCalls);
+ setCandidates(list);
+ // The least used is the natural one to pause: preselect it.
+ setChosen(list.length ? list[list.length - 1].id : null);
+ })();
+ return () => {
+ live = false;
+ };
+ }, [open, token, axis, target?.id, maxProp]);
+
+ const setActive = (id: string, isActive: boolean) =>
+ axis === 'mcpServers'
+ ? mcpServersApi.update(id, { isActive }, token!)
+ : connectorsApi.update(id, { isActive }, token!);
+
+ const run = async () => {
+ if (!token || !chosen) return;
+ setWorking(true);
+ setError('');
+ try {
+ await setActive(chosen, false);
+ } catch (e: any) {
+ setError(t('error', { message: e?.message ?? '' }));
+ setWorking(false);
+ return;
+ }
+ if (!target) {
+ const name = candidates?.find((c) => c.id === chosen)?.name ?? '';
+ setDone(t('pausedDone', { name }));
+ setCandidates((prev) => prev?.filter((c) => c.id !== chosen) ?? null);
+ setChosen(null);
+ setWorking(false);
+ onDone?.();
+ return;
+ }
+ try {
+ if (activate) await activate();
+ else await setActive(target.id, true);
+ setWorking(false);
+ onOpenChange(false);
+ onDone?.();
+ } catch (e: any) {
+ // Put the paused one back, so a failed swap changes nothing.
+ await setActive(chosen, true).catch(() => {});
+ setError(t('error', { message: e?.message ?? '' }));
+ setWorking(false);
+ }
+ };
+
+ const titleMax = max ?? candidates?.length ?? 0;
+
+ return (
+ !working && onOpenChange(o)}>
+
+
+
+
+ {t(`title.${axis}`, { max: titleMax })}
+
+
+ {target ? t('body', { name: target.name }) : t('bodyCreate')}
+
+
+
+ {candidates === null ? (
+
{t('loading')}
+ ) : candidates.length === 0 ? (
+
{done || t('empty')}
+ ) : (
+ <>
+
+ {ranked ? t('listLabel') : t('listLabelPlain')}
+
+
+ {candidates.map((c) => {
+ const selected = chosen === c.id;
+ return (
+
+ );
+ })}
+
+ {done &&
{done}
}
+ >
+ )}
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+
+
+ {t('cancel')}
+
+
+
+
+
+
+
+ );
+}
+
+/**
+ * A creation (or an invitation) refused at the plan's limit: the server's
+ * reason in plain words, "Pause one" (opens the swap dialog to free a slot)
+ * and the upgrade. Shown instead of a bare red error.
+ */
+export function PlanLimitNotice({ limit, onFreed }: { limit: PlanLimitError; onFreed?: () => void }) {
+ const t = useTranslations('planSwap');
+ const { usage } = usePlanUsage();
+ const [swapOpen, setSwapOpen] = useState(false);
+ const canSwap = limit.axis !== 'users';
+ return (
+
+
{t(`notice.${limit.axis}`, { max: limit.max })}
+
+ {canSwap && (
+
+ )}
+
+
+ {canSwap && (
+
+ )}
+
+ );
+}
diff --git a/packages/frontend/src/components/plan-usage-meters.tsx b/packages/frontend/src/components/plan-usage-meters.tsx
new file mode 100644
index 000000000..acb5f97c0
--- /dev/null
+++ b/packages/frontend/src/components/plan-usage-meters.tsx
@@ -0,0 +1,151 @@
+'use client';
+
+import { useState } from 'react';
+import { useFormatter, useTranslations } from 'next-intl';
+import { ChevronDown } from 'lucide-react';
+import { productEvents, type FitAxis } from '@/lib/api';
+import { useAuth } from '@/lib/auth-context';
+import { fitFor, formatStorage, planById } from '@/lib/card-trial';
+import { fitPlanOf, pausedByPlanSets, usePlanFit, usePlanUsage } from '@/lib/use-plan-fit';
+import { deviceKind } from '@/lib/paywall-tracking';
+import { PlanFitNotice } from '@/components/plan-fit-notice';
+import { Card } from '@/components/ui/card';
+import { cn } from '@/lib/utils';
+
+/**
+ * Settings → License on Cloud: the workspace's usage against its plan, one
+ * meter per limit (connectors, MCP servers, users, File Search folders, files
+ * and storage, saved code tools, code runs this month) and whether inline code
+ * is included. Built from the plan fit of the plan in use (a trial runs on
+ * Team's limits). During a trial, "See what changes with Starter" opens the
+ * same block as the checkout. Renders nothing when the server has no plan fit
+ * (self-hosted, Enterprise, an older backend).
+ */
+export function PlanUsageMeters({ plan }: { plan: string | null | undefined }) {
+ const t = useTranslations('planUsage');
+ const { token } = useAuth();
+ const fitPlan = fitPlanOf(plan);
+ const fits = usePlanFit(!!fitPlan);
+ const { usage } = usePlanUsage();
+ const [showStarter, setShowStarter] = useState(false);
+ const fit = fitPlan ? fitFor(fits, fitPlan) : null;
+ if (!fit) return null;
+
+ const trial = plan === 'trial';
+ const paused = pausedByPlanSets(usage);
+ const starter = trial ? fitFor(fits, 'starter') : null;
+ const track = (action: 'plan_fit_shown' | 'plan_fit_details_opened', p: 'starter' | 'team') => {
+ if (token) productEvents.track('paywall_action', token, { source: 'license_page', action, plan: p, device: deviceKind() });
+ };
+
+ const inline = fit.code.inlineIncluded
+ ? t('included')
+ : fit.code.inlineInUse
+ ? t('inUseNotIncluded')
+ : t('notIncluded');
+
+ return (
+
+
+ {trial ? t('titleTrial') : t('title', { plan: planById(fit.plan).name })}
+
+
+
+
+
+
+
+
+
+ {/* A trial without a card has a lower run cap than Team: no max shown. */}
+
+
+ {t('inlineCode')}
+
+ {inline}
+
+
+
+
+ {starter && !starter.fits && (
+
+
+ {showStarter &&
}
+
+ )}
+
+ );
+}
+
+function Meter({
+ label,
+ axis,
+ note,
+ format: formatProp,
+ hideUnlimited = false,
+}: {
+ label: string;
+ axis: FitAxis;
+ note?: string | null;
+ format?: (n: number) => string;
+ hideUnlimited?: boolean;
+}) {
+ const t = useTranslations('planUsage');
+ const intl = useFormatter();
+ const format = formatProp ?? ((n: number) => intl.number(n));
+ const limited = axis.max != null;
+ const share = limited ? (axis.max === 0 ? (axis.current > 0 ? 1 : 0) : Math.min(1, axis.current / axis.max!)) : 0;
+ const value = limited
+ ? `${format(axis.current)} / ${format(axis.max!)}`
+ : hideUnlimited
+ ? format(axis.current)
+ : t('unlimited', { current: format(axis.current) });
+ return (
+
+
+ {label}
+
+ {value}
+
+
+ {limited && (
+
+
0 ? 3 : 0)}%`,
+ background: axis.over ? 'var(--t-warn-fg)' : 'var(--brand)',
+ }}
+ />
+
+ )}
+ {note &&
{note}
}
+
+ );
+}
diff --git a/packages/frontend/src/components/trial-banner.tsx b/packages/frontend/src/components/trial-banner.tsx
index 506eac354..fea5ef00f 100644
--- a/packages/frontend/src/components/trial-banner.tsx
+++ b/packages/frontend/src/components/trial-banner.tsx
@@ -2,7 +2,14 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
-import { license } from '@/lib/api';
+import Link from 'next/link';
+import * as Dialog from '@radix-ui/react-dialog';
+import { license, productEvents } from '@/lib/api';
+import { PlanFitNotice } from '@/components/plan-fit-notice';
+import { buttonVariants } from '@/components/ui/button';
+import { usePlanFit } from '@/lib/use-plan-fit';
+import { deviceKind } from '@/lib/paywall-tracking';
+import { cn } from '@/lib/utils';
import { useAuth } from '@/lib/auth-context';
import { usePricingUrl } from '@/lib/use-pricing-url';
import { useCardCheckout } from '@/lib/use-card-checkout';
@@ -12,6 +19,7 @@ import { startTrialPath } from '@/lib/checkout-source';
import {
cardTrialDisplayEnd,
cardTrialEligible,
+ fitFor,
formatTrialEnd,
readPlanIntent,
} from '@/lib/card-trial';
@@ -25,7 +33,7 @@ export function TrialBanner() {
const [plan, setPlan] = useState
(null);
const [status, setStatus] = useState(null);
const [expiresAt, setExpiresAt] = useState(null);
- const [connectors, setConnectors] = useState(null);
+ const [changesOpen, setChangesOpen] = useState(false);
useEffect(() => {
if (!token) return;
@@ -37,11 +45,6 @@ export function TrialBanner() {
setDaysLeft(status.trialDaysLeft);
}
}).catch(() => {});
- // Value framing: how much the user has already built (so the upgrade
- // protects something concrete, not just "your trial ends").
- license.getUsage(token)
- .then((u) => setConnectors(u?.connectors?.current ?? null))
- .catch(() => {});
}, [token]);
const isTrialAdmin = deploymentMode === 'cloud' && user?.role === 'ADMIN' && plan === 'trial' && status === 'active';
@@ -49,6 +52,12 @@ export function TrialBanner() {
// The first-success offer on this page asks for the same thing: one call
// to action at a time, so the banner keeps only its countdown then.
const offerOnScreen = useFirstSuccessOfferVisible();
+ // The trial has everything in Team. In its last 3 days, an admin whose
+ // workspace is above Starter can see what Starter would change, before the
+ // plan is chosen. Plan fit is only asked for then.
+ const lastDays = isTrialAdmin && daysLeft !== null && daysLeft <= 3;
+ const starterFit = fitFor(usePlanFit(lastDays, 'starter'), 'starter');
+ const aboveStarter = lastDays && !!starterFit && !starterFit.fits;
// An ended trial is reported too (status 'expired'); the licence wall
// covers that case, so the countdown banner stays out of the way.
@@ -66,11 +75,9 @@ export function TrialBanner() {
? 'Your trial expires tomorrow.'
: `Trial: ${daysLeft} days left.`;
- // Only show the value clause once they've actually built something.
- const value =
- connectors && connectors > 0
- ? ` Keep your ${connectors} connector${connectors === 1 ? '' : 's'} running.`
- : '';
+ const trackChange = (action: 'plan_fit_shown' | 'plan_fit_details_opened', plan: 'starter' | 'team') => {
+ if (token) productEvents.track('paywall_action', token, { source: 'trial_banner', action, plan, device: deviceKind() });
+ };
// Cloud admins on a running trial add a card instead (the card trial ends
// with the free trial, nothing is charged before). Everyone else, and
@@ -106,7 +113,44 @@ export function TrialBanner() {
color: `var(--t-${tone}-fg)`,
}}
>
- {countdown}{value}{' '}
+ {countdown} Your trial includes everything in Team.{' '}
+ {aboveStarter && (
+ <>
+
+ {offerOnScreen ? ' ' : ' · '}
+
+
+
+
+
+ Your workspace is above Starter
+
+
+ Your trial includes everything in Team. If you choose Starter, this changes when the trial ends.
+ Team keeps everything as it is.
+
+ {changesOpen && }
+
+ Close
+ setChangesOpen(false)}
+ className={cn(buttonVariants({ variant: 'primary', size: 'md' }))}
+ >
+ Choose a plan
+
+
+
+
+
+ >
+ )}
{offerOnScreen ? null : offerCard ? (
+ {/* What the chosen plan pauses or leaves out, before checkout.
+ Counts only: the page has no sign-in, so no names. */}
+
+ track(action, { plan })}
+ onChooseTeam={
+ !opening && fitFor(view.planFit, 'team')?.fits
+ ? () => pickPlan({ ...selection, plan: 'team' })
+ : undefined
+ }
+ />
+