@@ -1777,7 +1849,7 @@ export default function ConnectorDetailPage() {
{t('details.instructionsHint')}
-
+ {inviteLimit && }
+
{msg && (
{msg}
diff --git a/packages/frontend/src/app/start-trial/page.tsx b/packages/frontend/src/app/start-trial/page.tsx
index f2614e8d8..a4c1d09c1 100644
--- a/packages/frontend/src/app/start-trial/page.tsx
+++ b/packages/frontend/src/app/start-trial/page.tsx
@@ -1,10 +1,11 @@
'use client';
+import { PlanFitNotice } from '@/components/plan-fit-notice';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { useAuth } from '@/lib/auth-context';
-import { license, productEvents } from '@/lib/api';
+import { license, productEvents, type PlanFits } from '@/lib/api';
import { LogoIcon } from '@/components/logo-icon';
import { PlanPicker } from '@/components/plan-picker';
import { Button } from '@/components/ui/button';
@@ -16,9 +17,11 @@ import {
formatEur,
formatTrialEnd,
planById,
+ fitFor,
readAuthorizationReturn,
readPlanIntent,
readPromoCode,
+ selectionForFit,
takeAuthorizationReturn,
trialRunning,
writeCardTrialPrompt,
@@ -91,6 +94,12 @@ export default function StartTrialPage() {
// Which entry point sent the user here (?source=…), for Stripe and the events.
const [source, setSource] = useState('start_trial');
const viewReported = useRef(false);
+ // What Starter and Team keep and pause for this workspace, shown under the
+ // plans before the card is added (the plan changes when the trial converts).
+ const [planFit, setPlanFit] = useState(null);
+ // Set once the plan is chosen (pricing page intent or a click): the plan fit
+ // no longer moves the selection then.
+ const picked = useRef(false);
// Set when the user came from "Connect" in an AI client: every way out of
// this page goes back to that authorization.
const [authorizationReturn, setAuthorizationReturn] = useState(null);
@@ -132,7 +141,9 @@ export default function StartTrialPage() {
setFreeTrialEnd(lic.expiresAt ?? null);
setAuthorizationReturn(readAuthorizationReturn());
setTrialEnd(cardTrialDisplayEnd(lic.expiresAt));
- setSelection(readPlanIntent() ?? DEFAULT_SELECTION);
+ const intent = readPlanIntent();
+ picked.current = !!intent;
+ setSelection(intent ?? DEFAULT_SELECTION);
setPromo(readPromoCode());
const from = parseCheckoutSource(new URLSearchParams(window.location.search).get('source')) ?? 'start_trial';
setSource(from);
@@ -150,6 +161,28 @@ export default function StartTrialPage() {
};
}, [isLoading, deploymentModeLoaded, deploymentMode, token, user, router, leave]);
+ useEffect(() => {
+ if (!ready || !token) return;
+ let live = true;
+ license
+ .getPlanFit(token)
+ .then((r) => {
+ const plans = r?.plans && typeof r.plans === 'object' ? r.plans : null;
+ if (!live || !plans) return;
+ setPlanFit(plans);
+ if (!picked.current) setSelection((prev) => selectionForFit(prev, plans));
+ })
+ .catch(() => {});
+ return () => {
+ live = false;
+ };
+ }, [ready, token]);
+
+ const choosePlan = (next: PlanSelection) => {
+ picked.current = true;
+ setSelection(next);
+ };
+
// The licence site refused the trial at checkout: say so before sending anyone on.
const paysToday = kind !== 'trial' || !!checkout.chargesTodayUrl;
@@ -254,7 +287,7 @@ export default function StartTrialPage() {
@@ -301,6 +334,18 @@ export default function StartTrialPage() {
+ {/* What the chosen plan changes for this workspace: under the card, in the same column. */}
+ {
+ if (token) productEvents.track('paywall_action', token, { source, action, plan, device: deviceKind() });
+ }}
+ onChooseTeam={
+ !checkout.loading && fitFor(planFit, 'team')?.fits ? () => choosePlan({ ...selection, plan: 'team' }) : undefined
+ }
+ />
);
diff --git a/packages/frontend/src/components/license-wall.tsx b/packages/frontend/src/components/license-wall.tsx
index 98cb5281a..eb5cf1d65 100644
--- a/packages/frontend/src/components/license-wall.tsx
+++ b/packages/frontend/src/components/license-wall.tsx
@@ -1,10 +1,11 @@
'use client';
+import { PlanFitNotice } from '@/components/plan-fit-notice';
import { useState, useEffect, useRef } from 'react';
import { usePathname } from 'next/navigation';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
-import { license, productEvents, type LicenseWallView } from '@/lib/api';
+import { license, productEvents, type LicenseWallView, type PlanFits } from '@/lib/api';
import { deviceKind, usePaywallLifecycle, type PaywallAction, type PaywallActionDetail } from '@/lib/paywall-tracking';
import { useTrustStats } from '@/lib/trust-stats';
import { directoryListingsFor } from '@/lib/marketing';
@@ -14,8 +15,10 @@ import { useManagePlan } from '@/lib/use-manage-plan';
import { useCardCheckout } from '@/lib/use-card-checkout';
import {
DEFAULT_SELECTION,
+ fitFor,
planById,
readPlanIntent,
+ selectionForFit,
selectionForUsage,
type PlanSelection,
type WorkspaceUsage,
@@ -49,6 +52,10 @@ export function LicenseWall() {
// What the workspace already has, so a plan it has outgrown is not the one
// preselected (and says so).
const [usage, setUsage] = useState(null);
+ // What each plan keeps and pauses for this workspace (Cloud), for the
+ // "What changes with Starter" block under the plans.
+ const [planFit, setPlanFit] = useState(null);
+ const planFitRef = useRef(null);
const tw = useTranslations('licenseWall');
const trustStats = useTrustStats(isCloud && reason === 'trial-ended' && user?.role === 'ADMIN');
// Once the admin picks a plan, the usage answer no longer moves it.
@@ -109,7 +116,27 @@ export function LicenseWall() {
users: u.users.current,
};
setUsage(current);
- if (!picked.current) setSelection(selectionForUsage(intent, current));
+ if (!picked.current) setSelection(selectionForFit(selectionForUsage(intent, current), planFitRef.current));
+ })
+ .catch(() => {});
+ return () => {
+ cancelled = true;
+ };
+ }, [buyInPlace, token]);
+
+ useEffect(() => {
+ if (!buyInPlace || !token) return;
+ let cancelled = false;
+ license
+ .getPlanFit(token)
+ .then((r) => {
+ const plans = r?.plans && typeof r.plans === 'object' ? r.plans : null;
+ if (cancelled || !plans) return;
+ planFitRef.current = plans;
+ setPlanFit(plans);
+ // Inline code or File Search can rule Starter out with few connectors:
+ // preselect the plan that fits, unless the admin already picked one.
+ if (!picked.current) setSelection((prev) => selectionForFit(prev, plans));
})
.catch(() => {});
return () => {
@@ -395,6 +422,14 @@ export function LicenseWall() {
{checkout.loading ? 'Opening checkout…' : `Subscribe to ${planById(selection.plan).name}`}
{checkout.error &&
diff --git a/packages/frontend/src/components/plan-fit-notice.tsx b/packages/frontend/src/components/plan-fit-notice.tsx
new file mode 100644
index 000000000..6e12ef4c6
--- /dev/null
+++ b/packages/frontend/src/components/plan-fit-notice.tsx
@@ -0,0 +1,163 @@
+'use client';
+
+import { useEffect, useId, useRef, useState } from 'react';
+import { useTranslations } from 'next-intl';
+import { Check, ChevronDown } from 'lucide-react';
+import type { FitItem, FitRanked, PlanFit } from '@/lib/api';
+import { planById, planFitLines } from '@/lib/card-trial';
+import { cn } from '@/lib/utils';
+
+export type PlanFitEvent = 'plan_fit_shown' | 'plan_fit_details_opened';
+
+/**
+ * "What changes with Starter": what this workspace, as it is today, keeps
+ * and loses on a plan (GET /api/license/plan-fit). Shown before checkout on
+ * the licence wall, the card-trial page, the trial banner and the
+ * reactivation page, so nobody learns after paying that something was paused.
+ *
+ * Informative, not a warning: neutral colours, short lines, and the plan
+ * stays selectable. When everything fits, one quiet line says so (`quietFit`).
+ * `anonymized` (the reactivation page, no sign-in) shows counts only and no
+ * list of names.
+ *
+ * Words come from the `planFit` messages, so a page in another language can
+ * wrap it in its own NextIntlClientProvider with those keys.
+ */
+export function PlanFitNotice({
+ fit,
+ anonymized = false,
+ quietFit = true,
+ onTrack,
+ onChooseTeam,
+ className,
+}: {
+ fit: PlanFit | null | undefined;
+ anonymized?: boolean;
+ /** When the workspace fits the plan: one quiet line (true) or nothing (false). */
+ quietFit?: boolean;
+ /** Reports plan_fit_shown (once per plan) and plan_fit_details_opened. */
+ onTrack?: (event: PlanFitEvent, plan: PlanFit['plan']) => void;
+ /** Offers a "Choose Team instead" link under a plan that does not fit. */
+ onChooseTeam?: () => void;
+ className?: string;
+}) {
+ const t = useTranslations('planFit');
+ const [open, setOpen] = useState(false);
+ const listId = useId();
+ const lines = planFitLines(fit);
+ const plan = fit?.plan;
+ const changes = lines.length > 0;
+
+ // plan_fit_shown once per plan and page, only when there is something to say.
+ const reported = useRef(new Set());
+ const trackRef = useRef(onTrack);
+ useEffect(() => {
+ trackRef.current = onTrack;
+ });
+ useEffect(() => {
+ if (!plan || !changes || reported.current.has(plan)) return;
+ reported.current.add(plan);
+ trackRef.current?.('plan_fit_shown', plan);
+ }, [plan, changes]);
+
+ if (!fit) return null;
+ const planName = planById(fit.plan).name;
+
+ if (!changes) {
+ if (!quietFit) return null;
+ return (
+
+ );
+}
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 && (
+ <>
+ setChangesOpen(true)}
+ className="underline font-medium hover:no-underline"
+ >
+ Above Starter: see what changes
+
+ {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 ? (
{});
}, [token, deploymentMode]);
- if (!usage || !usage.plan || !usage.isOverAny || dismissed) return null;
+ if (!usage || !usage.plan || dismissed) return null;
+
+ // After an automatic downgrade: say what the plan paused and how to swap.
+ // Calm on purpose: nothing was deleted, and the user picks what stays on.
+ const paused = pausedByPlanSets(usage);
+ const pausedConnectors = paused.connectors.size;
+ const pausedServers = paused.mcpServers.size;
+ if (pausedConnectors + pausedServers > 0) {
+ const parts = [
+ pausedConnectors ? `${pausedConnectors} connector${pausedConnectors === 1 ? '' : 's'}` : null,
+ pausedServers ? `${pausedServers} MCP server${pausedServers === 1 ? '' : 's'}` : null,
+ ].filter(Boolean);
+ const upgradeToTeam = usage.plan === 'starter';
+ return (
+
+
+ {parts.join(' and ')} {pausedConnectors + pausedServers === 1 ? 'is' : 'are'} paused by your plan.
+ {' '}
+
+ Switch which ones are active
+
+ {', or '}
+ {upgradeToTeam ? (
+
+ upgrade to Team
+
+ ) : (
+
+ ask about Enterprise
+
+ )}
+ .{' '}
+ setDismissed(true)}
+ aria-label="Dismiss"
+ className="ml-2 inline-flex h-5 w-5 items-center justify-center rounded hover:bg-black/10"
+ >
+ ×
+
+
+ );
+ }
+
+ if (!usage.isOverAny) return null;
const next = NEXT_TIER[usage.plan];
if (!next) return null;
diff --git a/packages/frontend/src/lib/api.ts b/packages/frontend/src/lib/api.ts
index a7c50ba78..9791bc22a 100644
--- a/packages/frontend/src/lib/api.ts
+++ b/packages/frontend/src/lib/api.ts
@@ -1628,6 +1628,12 @@ export interface ReactivationView {
/** The AI client whose chat showed the link (else the first one connected). */
client: 'Claude' | 'ChatGPT' | 'Cursor' | 'Copilot' | null;
usage: { connectors: number; mcpServers: number; users: number };
+ /**
+ * What the workspace keeps and loses on each plan, with no connector or
+ * server names (ids like `pause-0`, empty names): the page opens without a
+ * sign-in, so it gives counts only. Null or missing when it could not be read.
+ */
+ planFit?: Record | null;
/** The Telegram price test's arm, for a Telegram-only workspace that can buy; else null. */
offerArm?: 'personal' | 'list' | null;
/** The 'personal' arm's discounted Starter monthly price; null otherwise. */
@@ -2092,6 +2098,106 @@ export const support = {
}),
};
+// ── Plan fit (Cloud) ───────────────────────────────────────────────────────
+// Mirrors packages/backend/src/license/plan-fit.ts.
+
+export type FitPlanId = 'starter' | 'team';
+
+export interface FitAxis {
+ current: number;
+ /** null = unlimited on that plan. */
+ max: number | null;
+ over: boolean;
+}
+
+export interface FitItem {
+ id: string;
+ name: string;
+ calls30d: number;
+}
+
+export interface FitRanked extends FitAxis {
+ /** Stay active on that plan, most used first. */
+ keep: FitItem[];
+ /** Would be paused (never deleted), least used last. */
+ pause: FitItem[];
+}
+
+/** What this workspace, as it is today, keeps and loses on a plan. */
+export interface PlanFit {
+ plan: FitPlanId;
+ /** Everything the workspace uses today fits the plan. */
+ fits: boolean;
+ connectors: FitRanked;
+ mcpServers: FitRanked;
+ users: FitAxis;
+ fileSearch: {
+ connectors: FitRanked;
+ files: FitAxis;
+ bytes: FitAxis;
+ fileShare: boolean;
+ };
+ code: {
+ tools: FitAxis;
+ inlineInUse: boolean;
+ inlineIncluded: boolean;
+ runsThisMonth: FitAxis;
+ };
+}
+
+export type PlanFits = Partial>;
+
+/**
+ * A refusal because a plan limit is used up (HTTP 403, `code: 'PLAN_LIMIT'`):
+ * active connectors, MCP servers or File Search folders (activating or
+ * creating one more needs one paused first, or a bigger plan), or users (an
+ * invitation beyond the plan).
+ */
+export type PlanLimitAxis = 'connectors' | 'mcpServers' | 'users' | 'fileSearchConnectors';
+const PLAN_LIMIT_AXES: readonly string[] = ['connectors', 'mcpServers', 'users', 'fileSearchConnectors'];
+
+export interface PlanLimitError {
+ code: 'PLAN_LIMIT';
+ axis: PlanLimitAxis;
+ max: number;
+ current: number;
+ message: string;
+}
+
+/** The PLAN_LIMIT details of a refused request, or null for any other error. */
+export function planLimitOf(err: unknown): PlanLimitError | null {
+ if (!(err instanceof ApiError) || err.status !== 403) return null;
+ const b = err.body as Partial | null | undefined;
+ if (!b || b.code !== 'PLAN_LIMIT') return null;
+ if (typeof b.axis !== 'string' || !PLAN_LIMIT_AXES.includes(b.axis)) return null;
+ return {
+ code: 'PLAN_LIMIT',
+ axis: b.axis as PlanLimitAxis,
+ max: typeof b.max === 'number' ? b.max : 0,
+ current: typeof b.current === 'number' ? b.current : 0,
+ message: typeof b.message === 'string' && b.message ? b.message : err.message,
+ };
+}
+
+export function isPlanLimitError(err: unknown): boolean {
+ return planLimitOf(err) !== null;
+}
+
+/** GET /api/license/usage. */
+export interface LicenseUsage {
+ plan: string | null;
+ connectors: { current: number; max: number | null; isOver: boolean };
+ mcpServers: { current: number; max: number | null; isOver: boolean };
+ users: { current: number; max: number | null; isOver: boolean };
+ isOverAny: boolean;
+ /**
+ * Cloud: the connectors (File Search folders included) and MCP servers the
+ * last automatic downgrade paused, still paused. Missing on servers that do
+ * not pause by plan.
+ */
+ pausedByPlan?: { connectors: string[]; mcpServers: string[] };
+}
+
// License
export const license = {
getEdition: (token: string) =>
@@ -2211,13 +2317,13 @@ export const license = {
getInstanceId: () =>
request<{ instanceId: string }>('/api/license/instance-id'),
getUsage: (token?: string) =>
- request<{
- plan: string | null;
- connectors: { current: number; max: number | null; isOver: boolean };
- mcpServers: { current: number; max: number | null; isOver: boolean };
- users: { current: number; max: number | null; isOver: boolean };
- isOverAny: boolean;
- }>('/api/license/usage', { token }),
+ request('/api/license/usage', { token }),
+ /**
+ * Cloud: what this workspace keeps and loses on Starter and Team (or on
+ * `plan` only). 404 on self-hosted, where these limits do not apply.
+ */
+ getPlanFit: (token: string, plan?: FitPlanId) =>
+ request<{ plans?: PlanFits }>(`/api/license/plan-fit${plan ? `?plan=${plan}` : ''}`, { token }),
};
export interface McpServer {
diff --git a/packages/frontend/src/lib/card-trial.ts b/packages/frontend/src/lib/card-trial.ts
index 8596a377e..40c001c9b 100644
--- a/packages/frontend/src/lib/card-trial.ts
+++ b/packages/frontend/src/lib/card-trial.ts
@@ -1,3 +1,4 @@
+import type { PlanFit, PlanFits } from './api';
import { storage } from './storage';
import { safeRedirect } from './safe-redirect';
import { startTrialPath } from './checkout-source';
@@ -105,6 +106,101 @@ export function describeUsage(usage: WorkspaceUsage, keys: (keyof PlanLimits)[])
return keys.map((k) => `${usage[k]} ${label[k]}${usage[k] === 1 ? '' : 's'}`).join(', ');
}
+// ── What changes on a plan (GET /api/license/plan-fit) ──────────────────────
+
+/**
+ * One line of the "What changes with Starter" block: a message key under
+ * `planFit.lines` and its values. The words live in the messages (dashboard)
+ * or in the reactivation page's copy, so the same lines read in every UI
+ * language; numbers stay numbers so each language formats them itself.
+ */
+export type PlanFitLine =
+ | { key: 'connectors'; values: { paused: number; total: number; max: number } }
+ | { key: 'mcpServers' | 'mcpServersChoose'; values: { paused: number; total: number; max: number } }
+ | { key: 'users'; values: { plan: string; max: number; current: number } }
+ | {
+ key: 'fileSearch';
+ values: { maxFolders: number; maxFiles: number; folders: number; files: number };
+ }
+ | { key: 'fileSearchStorage'; values: { maxSize: string; size: string } }
+ | { key: 'codeTools'; values: { max: number; current: number } }
+ | { key: 'inline'; values: Record };
+
+/**
+ * What `fit` changes for this workspace, most important first: connectors
+ * and MCP servers that would be paused, users above the plan, File Search
+ * above its limits, saved code tools, inline code. Empty when the workspace
+ * fits the plan. The "you choose which ones" hint is said once, on the first
+ * line that pauses something.
+ */
+export function planFitLines(fit: PlanFit | null | undefined): PlanFitLine[] {
+ if (!fit) return [];
+ const lines: PlanFitLine[] = [];
+ const { connectors, mcpServers, users, fileSearch, code } = fit;
+ if (connectors.over && connectors.max != null) {
+ lines.push({
+ key: 'connectors',
+ values: { paused: connectors.pause.length || connectors.current - connectors.max, total: connectors.current, max: connectors.max },
+ });
+ }
+ if (mcpServers.over && mcpServers.max != null) {
+ lines.push({
+ key: connectors.over ? 'mcpServers' : 'mcpServersChoose',
+ values: { paused: mcpServers.pause.length || mcpServers.current - mcpServers.max, total: mcpServers.current, max: mcpServers.max },
+ });
+ }
+ if (users.over && users.max != null) {
+ lines.push({ key: 'users', values: { plan: planById(fit.plan).name, max: users.max, current: users.current } });
+ }
+ const fs = fileSearch;
+ if ((fs.connectors.over || fs.files.over) && fs.connectors.max != null && fs.files.max != null) {
+ lines.push({
+ key: 'fileSearch',
+ values: { maxFolders: fs.connectors.max, maxFiles: fs.files.max, folders: fs.connectors.current, files: fs.files.current },
+ });
+ } else if (fs.bytes.over && fs.bytes.max != null) {
+ lines.push({ key: 'fileSearchStorage', values: { maxSize: formatStorage(fs.bytes.max), size: formatStorage(fs.bytes.current) } });
+ }
+ if (code.tools.over && code.tools.max != null) {
+ lines.push({ key: 'codeTools', values: { max: code.tools.max, current: code.tools.current } });
+ }
+ if (code.inlineInUse && !code.inlineIncluded) lines.push({ key: 'inline', values: {} });
+ return lines;
+}
+
+/** The plan-fit answer for one plan, or null when the server gave none. */
+export function fitFor(fits: PlanFits | null | undefined, plan: CloudPlanId): PlanFit | null {
+ const fit = fits?.[plan];
+ return fit && typeof fit === 'object' && typeof fit.fits === 'boolean' ? fit : null;
+}
+
+/**
+ * The plan to preselect once the plan fit is known: the picked plan when
+ * everything fits it, otherwise Team when the workspace fits Team (inline
+ * code or File Search can rule Starter out even with few connectors).
+ * Without an answer for the picked plan, nothing changes.
+ */
+export function selectionForFit(picked: PlanSelection, fits: PlanFits | null | undefined): PlanSelection {
+ const fit = fitFor(fits, picked.plan);
+ if (!fit || fit.fits || picked.plan === 'team') return picked;
+ const team = fitFor(fits, 'team');
+ return team?.fits ? { ...picked, plan: 'team' } : picked;
+}
+
+/** Storage as the plans state it (binary units): "500 MB", "2 GB", "1.2 GB". */
+export function formatStorage(bytes: number): string {
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
+ let v = Math.max(0, bytes);
+ let i = 0;
+ while (v >= 1024 && i < units.length - 1) {
+ v /= 1024;
+ i++;
+ }
+ const rounded = v >= 100 ? Math.round(v) : Math.round(v * 10) / 10;
+ const text = Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
+ return `${text} ${units[i]}`;
+}
+
export function planById(id: CloudPlanId): CloudPlan {
return CLOUD_PLANS.find((p) => p.id === id) ?? CLOUD_PLANS[1];
}
diff --git a/packages/frontend/src/lib/paywall-tracking.ts b/packages/frontend/src/lib/paywall-tracking.ts
index 8ab527efc..80eeeb92e 100644
--- a/packages/frontend/src/lib/paywall-tracking.ts
+++ b/packages/frontend/src/lib/paywall-tracking.ts
@@ -21,7 +21,11 @@ export type PaywallAction =
| 'enterprise_clicked'
| 'support_clicked'
| 'delete_account_clicked'
- | 'left_without_subscribe';
+ | 'left_without_subscribe'
+ // The "What changes with " block was shown (metadata.plan) because the
+ // workspace is above that plan, and its list of items was opened.
+ | 'plan_fit_shown'
+ | 'plan_fit_details_opened';
export interface PaywallActionDetail {
plan?: 'starter' | 'team';
diff --git a/packages/frontend/src/lib/trust-stats.ts b/packages/frontend/src/lib/trust-stats.ts
index a2cde14f3..0555cdcc1 100644
--- a/packages/frontend/src/lib/trust-stats.ts
+++ b/packages/frontend/src/lib/trust-stats.ts
@@ -25,7 +25,7 @@ function floorPlus(n: unknown, unit: number): string | null {
}
/** Mirrors TRUST_FLOORS in the backend's trust-stats.format.ts. */
-export const TRUST_FLOORS = { dockerPulls: 200_000, toolCalls30d: 1_000_000 } as const;
+export const TRUST_FLOORS = { dockerPulls: 200_000, toolCalls30d: 2_000_000 } as const;
const atLeast = (n: unknown, floor: number) => (usable(n) && n > floor ? n : floor);
diff --git a/packages/frontend/src/lib/use-plan-fit.ts b/packages/frontend/src/lib/use-plan-fit.ts
new file mode 100644
index 000000000..a65cc0c9c
--- /dev/null
+++ b/packages/frontend/src/lib/use-plan-fit.ts
@@ -0,0 +1,91 @@
+'use client';
+
+import { useCallback, useEffect, useState } from 'react';
+import { license, type FitPlanId, type LicenseUsage, type PlanFits } from './api';
+import { useAuth } from './auth-context';
+
+/**
+ * Cloud: what this workspace keeps and loses on Starter and Team
+ * (GET /api/license/plan-fit). undefined while loading or when `enabled` is
+ * false; null when the server has no answer (self-hosted answers 404, an
+ * older backend has no endpoint), so callers simply show nothing.
+ */
+export function usePlanFit(enabled: boolean, plan?: FitPlanId): PlanFits | null | undefined {
+ const { token, deploymentMode } = useAuth();
+ const [fits, setFits] = useState(undefined);
+ const on = enabled && !!token && deploymentMode === 'cloud';
+ useEffect(() => {
+ if (!on || !token) return;
+ let live = true;
+ license
+ .getPlanFit(token, plan)
+ .then((r) => {
+ if (live) setFits(r && typeof r.plans === 'object' && r.plans ? r.plans : null);
+ })
+ .catch(() => {
+ if (live) setFits(null);
+ });
+ return () => {
+ live = false;
+ };
+ }, [on, token, plan]);
+ return on ? fits : undefined;
+}
+
+/**
+ * Cloud: the workspace's usage against its plan (GET /api/license/usage),
+ * with the connectors and MCP servers the last downgrade paused. null until
+ * loaded, and always on self-hosted. `reload` asks again (after a swap).
+ */
+export function usePlanUsage(): { usage: LicenseUsage | null; reload: () => void } {
+ const { token, deploymentMode } = useAuth();
+ const [usage, setUsage] = useState(null);
+ const [nonce, setNonce] = useState(0);
+ const cloud = deploymentMode === 'cloud';
+ useEffect(() => {
+ if (!token || !cloud) return;
+ let live = true;
+ license
+ .getUsage(token)
+ .then((u) => {
+ if (live) setUsage(u && typeof u === 'object' && u.connectors ? u : null);
+ })
+ .catch(() => {});
+ return () => {
+ live = false;
+ };
+ }, [token, cloud, nonce]);
+ const reload = useCallback(() => setNonce((n) => n + 1), []);
+ return { usage: cloud ? usage : null, reload };
+}
+
+/** The ids the last automatic downgrade paused, as sets; empty when none or unknown. */
+export function pausedByPlanSets(usage: LicenseUsage | null | undefined): {
+ connectors: ReadonlySet;
+ mcpServers: ReadonlySet;
+} {
+ const p = usage?.pausedByPlan;
+ return {
+ connectors: new Set(Array.isArray(p?.connectors) ? p.connectors : []),
+ mcpServers: new Set(Array.isArray(p?.mcpServers) ? p.mcpServers : []),
+ };
+}
+
+/**
+ * Whether one more active item of `axis` is over the plan, as far as the
+ * client can tell. Only once the server pauses by plan (it reports
+ * `pausedByPlan`): before that, the caps are advisory and activating stays
+ * allowed, so the client must not refuse what the server accepts.
+ */
+export function atPlanCap(usage: LicenseUsage | null | undefined, axis: 'connectors' | 'mcpServers'): boolean {
+ if (!usage || usage.pausedByPlan === undefined) return false;
+ const a = usage[axis];
+ return !!a && a.max != null && a.current >= a.max;
+}
+
+/** The plan whose limits apply now, for plan-fit: a trial runs on Team's. */
+export function fitPlanOf(plan: string | null | undefined): FitPlanId | null {
+ if (plan === 'starter') return 'starter';
+ if (plan === 'team' || plan === 'trial') return 'team';
+ return null;
+}
diff --git a/packages/frontend/tests/e2e/plan-fit.spec.ts b/packages/frontend/tests/e2e/plan-fit.spec.ts
new file mode 100644
index 000000000..53285d772
--- /dev/null
+++ b/packages/frontend/tests/e2e/plan-fit.spec.ts
@@ -0,0 +1,448 @@
+import { readFileSync } from 'fs';
+import { join } from 'path';
+import { expect, test, type Page } from '@playwright/test';
+import { createTranslator } from 'next-intl';
+import type { PlanFit } from '../../src/lib/api';
+import { fitFor, formatStorage, planFitLines, selectionForFit } from '../../src/lib/card-trial';
+import { PLAN_FIT_COPY } from '../../src/app/reactivate/[token]/copy';
+
+/**
+ * The "What changes with Starter" block (Cloud): what a workspace above
+ * Starter would have paused or not included, shown before checkout, and the
+ * swap at the plan's cap after a downgrade.
+ *
+ * The first block tests the pure helpers against the English messages. The
+ * rest is UI smoke with the API mocked (the same fake session as the other
+ * specs: seed the cookie and localStorage, stub /api/* and /health/*).
+ */
+
+const en = JSON.parse(readFileSync(join(__dirname, '../../messages/en.json'), 'utf8'));
+const tEn = createTranslator({ locale: 'en', messages: en, namespace: 'planFit' });
+
+const axis = (current: number, max: number | null) => ({ current, max, over: max != null && current > max });
+const ranked = (names: string[], max: number | null, calls: number[] = []) => {
+ const items = names.map((name, i) => ({ id: `${name.toLowerCase()}-id`, name, calls30d: calls[i] ?? 0 }));
+ const cut = max == null ? items.length : max;
+ return { ...axis(items.length, max), keep: items.slice(0, cut), pause: items.slice(cut) };
+};
+
+const NINE = ['Odoo', 'Shopify', 'Stripe', 'Gmail', 'Slack', 'Etsy', 'Notion', 'Trello', 'Asana'];
+const CALLS = [420, 310, 120, 80, 33, 9, 4, 1, 0];
+
+function starterFit(overrides: Partial = {}): PlanFit {
+ return {
+ plan: 'starter',
+ fits: false,
+ connectors: ranked(NINE, 5, CALLS),
+ mcpServers: ranked(['Main', 'Sales', 'Ops', 'Test'], 3, [90, 20, 5, 0]),
+ users: axis(2, 1),
+ fileSearch: {
+ connectors: ranked(['NAS', 'Drive'], 1, [10, 2]),
+ files: axis(1200, 500),
+ bytes: axis(300 * 1024 * 1024, 500 * 1024 * 1024),
+ fileShare: true,
+ },
+ code: {
+ tools: axis(7, 5),
+ inlineInUse: true,
+ inlineIncluded: false,
+ runsThisMonth: axis(120, 500),
+ },
+ ...overrides,
+ };
+}
+
+function teamFit(): PlanFit {
+ return {
+ plan: 'team',
+ fits: true,
+ connectors: ranked(NINE, 15, CALLS),
+ mcpServers: ranked(['Main', 'Sales', 'Ops', 'Test'], 10, [90, 20, 5, 0]),
+ users: axis(2, 3),
+ fileSearch: {
+ connectors: ranked(['NAS', 'Drive'], 3, [10, 2]),
+ files: axis(1200, 2000),
+ bytes: axis(300 * 1024 * 1024, 2 * 1024 * 1024 * 1024),
+ fileShare: true,
+ },
+ code: { tools: axis(7, 20), inlineInUse: true, inlineIncluded: true, runsThisMonth: axis(120, 5000) },
+ };
+}
+
+test.describe('plan-fit helpers', () => {
+ test('turns a plan fit into short lines, most important first', () => {
+ const lines = planFitLines(starterFit()).map((l) => tEn(`lines.${l.key}`, l.values));
+ expect(lines).toEqual([
+ '4 of 9 connectors paused (least used). You choose which.',
+ '1 of 4 MCP servers paused (least used).',
+ '1 user included, you have 2. Nobody loses access.',
+ 'File Search: up to 1 folder and 500 files.',
+ '5 saved code tools (you have 7).',
+ 'No inline code (code_execute).',
+ ]);
+ });
+
+ test('says "you choose" once, on the first line that pauses something', () => {
+ const fit = starterFit({ connectors: ranked(['Odoo', 'Shopify'], 5) });
+ const lines = planFitLines(fit).map((l) => tEn(`lines.${l.key}`, l.values));
+ expect(lines[0]).toBe('1 of 4 MCP servers paused (least used). You choose which.');
+ });
+
+ test('names storage when only the bytes go past the plan', () => {
+ const fit = starterFit({
+ connectors: ranked(['Odoo'], 5),
+ mcpServers: ranked(['Main'], 3),
+ users: axis(1, 1),
+ fileSearch: {
+ connectors: ranked(['NAS'], 1),
+ files: axis(300, 500),
+ bytes: axis(1.2 * 1024 * 1024 * 1024, 500 * 1024 * 1024),
+ fileShare: true,
+ },
+ code: { tools: axis(1, 5), inlineInUse: false, inlineIncluded: false, runsThisMonth: axis(0, 500) },
+ });
+ expect(planFitLines(fit).map((l) => tEn(`lines.${l.key}`, l.values))).toEqual([
+ 'File Search: up to 500 MB of storage.',
+ ]);
+ });
+
+ test('has nothing to say when the workspace fits', () => {
+ expect(planFitLines(teamFit())).toEqual([]);
+ expect(planFitLines(null)).toEqual([]);
+ // Inline code that is not switched on is no change.
+ const quiet = starterFit({
+ connectors: ranked(['Odoo'], 5),
+ mcpServers: ranked(['Main'], 3),
+ users: axis(1, 1),
+ fileSearch: { connectors: ranked([], 1), files: axis(0, 500), bytes: axis(0, 500 * 1024 * 1024), fileShare: true },
+ code: { tools: axis(0, 5), inlineInUse: false, inlineIncluded: false, runsThisMonth: axis(0, 500) },
+ fits: true,
+ });
+ expect(planFitLines(quiet)).toEqual([]);
+ });
+
+ test('every page language of the reactivation page words every line', () => {
+ const lines = planFitLines(starterFit());
+ for (const [lang, copy] of Object.entries(PLAN_FIT_COPY)) {
+ const t = createTranslator({ locale: lang, messages: { planFit: copy }, namespace: 'planFit' });
+ for (const l of lines) {
+ const text = t(`lines.${l.key}`, l.values);
+ expect(text, `${lang} ${l.key}`).not.toContain('{');
+ expect(text, `${lang} ${l.key}`).not.toMatch(/[—–]/);
+ }
+ }
+ const tAnon = createTranslator({ locale: 'en', messages: { planFit: PLAN_FIT_COPY.en }, namespace: 'planFit' });
+ expect(tAnon('lines.connectors', { paused: 4, total: 9, max: 5 })).toBe(
+ '4 of 9 connectors paused (least used). You choose which after signing in.',
+ );
+ });
+
+ test('preselects Team when Starter does not fit and Team does', () => {
+ const plans = { starter: starterFit(), team: teamFit() };
+ expect(selectionForFit({ plan: 'starter', period: 'yearly' }, plans)).toEqual({ plan: 'team', period: 'yearly' });
+ expect(selectionForFit({ plan: 'team', period: 'monthly' }, plans)).toEqual({ plan: 'team', period: 'monthly' });
+ // No answer, or past Team too: the selection stays.
+ expect(selectionForFit({ plan: 'starter', period: 'monthly' }, null)).toEqual({ plan: 'starter', period: 'monthly' });
+ expect(
+ selectionForFit({ plan: 'starter', period: 'monthly' }, { starter: starterFit(), team: { ...teamFit(), fits: false } }),
+ ).toEqual({ plan: 'starter', period: 'monthly' });
+ expect(fitFor({}, 'starter')).toBeNull();
+ });
+
+ test('formats storage like the plans state it', () => {
+ expect(formatStorage(500 * 1024 * 1024)).toBe('500 MB');
+ expect(formatStorage(2 * 1024 * 1024 * 1024)).toBe('2 GB');
+ expect(formatStorage(1.25 * 1024 * 1024 * 1024)).toBe('1.3 GB');
+ expect(formatStorage(0)).toBe('0 B');
+ });
+});
+
+// ── UI smoke ────────────────────────────────────────────────────────────────
+
+const ADMIN = {
+ id: 'u1',
+ email: 'admin@example.test',
+ name: 'Ada Admin',
+ role: 'ADMIN',
+ organizationId: 'o1',
+ emailVerified: true,
+};
+
+type Usage = {
+ plan: string;
+ connectors: { current: number; max: number | null; isOver: boolean };
+ mcpServers: { current: number; max: number | null; isOver: boolean };
+ users: { current: number; max: number | null; isOver: boolean };
+ isOverAny: boolean;
+ pausedByPlan?: { connectors: string[]; mcpServers: string[] };
+};
+
+async function cloudSession(
+ page: Page,
+ opts: {
+ license: Record;
+ usage: Usage;
+ planFit?: Record | null;
+ connectors?: Array>;
+ wall?: Record | null;
+ },
+) {
+ const events: Array<{ event: string; metadata?: Record }> = [];
+ const updates: Array<{ id: string; body: unknown }> = [];
+ const connectorList = opts.connectors ?? [];
+ const origin = test.info().project.use.baseURL ?? 'http://localhost:3100';
+ await page.context().addCookies([{ name: 'amcp_token', value: 't', url: origin }]);
+ await page.addInitScript((user) => {
+ localStorage.setItem('amcp_token', 't');
+ localStorage.setItem('amcp_user', JSON.stringify(user));
+ localStorage.setItem('amcp_card_trial_prompt:u1', 'shown');
+ }, ADMIN);
+ await page.route(/\/(api|health)\//, async (route) => {
+ const req = route.request();
+ const p = new URL(req.url()).pathname;
+ const json = (body: unknown, status = 200) =>
+ route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) });
+ if (p === '/health/server-info') {
+ return json({ deploymentMode: 'cloud', mcpAuthMode: 'oauth2', hasUsers: true, registrationEnabled: true, ssoProviders: [] });
+ }
+ if (p === '/api/price-estimate') return json({ currency: null, vat: true });
+ if (p.endsWith('/product-events') && req.method() === 'POST') {
+ events.push(req.postDataJSON());
+ return route.fulfill({ status: 204 });
+ }
+ if (p.endsWith('/users/me/onboarding-state')) return json({ onboardingCompletedAt: '2026-10-01' });
+ if (p.endsWith('/users/me')) return json(ADMIN);
+ if (p.endsWith('/license/status')) return json(opts.license);
+ if (p.endsWith('/license/usage')) return json(opts.usage);
+ if (p.endsWith('/license/plan-fit')) {
+ if (opts.planFit === null) return json({ message: 'Not Found' }, 404);
+ const plan = new URL(req.url()).searchParams.get('plan');
+ const plans = opts.planFit ?? {};
+ return json({ plans: plan ? { [plan]: plans[plan] } : plans });
+ }
+ if (p.endsWith('/license/wall')) return json({ wall: opts.wall ?? null });
+ if (p.endsWith('/license/card-trial-eligibility')) return json({ kind: 'trial', trialEndsAt: null });
+ if (p.endsWith('/organizations/mine')) return json([{ id: 'o1', name: 'Main', role: 'ADMIN', joinedAt: '2026-10-01' }]);
+ if (p.endsWith('/organizations/current')) return json({ id: 'o1', name: 'Main', createdAt: '2026-10-01' });
+ const one = /\/api\/connectors\/([^/]+)$/.exec(p);
+ if (one && req.method() === 'PUT') {
+ const body = req.postDataJSON();
+ updates.push({ id: one[1], body });
+ const c = connectorList.find((x) => x.id === one[1]);
+ if (c) Object.assign(c, body);
+ return json({});
+ }
+ if (p === '/api/connectors') return json(connectorList);
+ if (p.endsWith('/adapters/starter-pack')) return json([]);
+ if (p.endsWith('/adapters')) return json([]);
+ return json({});
+ });
+ return { events, updates };
+}
+
+const ENDED = { plan: 'trial', status: 'active', expiresAt: new Date(Date.now() - 86_400_000).toISOString(), trialDaysLeft: 0, features: {} };
+const TRIAL_USAGE: Usage = {
+ plan: 'trial',
+ connectors: { current: 9, max: 15, isOver: false },
+ mcpServers: { current: 4, max: 10, isOver: false },
+ users: { current: 2, max: 3, isOver: false },
+ isOverAny: false,
+};
+
+test.describe('plan fit on the paywall (cloud)', () => {
+ test('the ended-trial wall says what Starter would change, and Team fits', async ({ page }) => {
+ const { events } = await cloudSession(page, {
+ license: ENDED,
+ usage: TRIAL_USAGE,
+ planFit: { starter: starterFit(), team: teamFit() },
+ wall: { variant: 'trust', recap: null },
+ });
+ await page.goto('/connectors');
+ await expect(page.getByRole('heading', { name: 'Keep your AI connected to your apps' })).toBeVisible({ timeout: 15_000 });
+ // Above Starter: Team is preselected, and says it fits.
+ await expect(page.getByRole('radio', { name: /Team/ })).toHaveAttribute('aria-checked', 'true');
+ await expect(page.getByTestId('plan-fit-fits')).toHaveText('Everything you use today fits Team.');
+
+ // Starter stays selectable, and says what it changes.
+ await page.getByRole('radio', { name: /Starter/ }).click();
+ const notice = page.getByTestId('plan-fit-notice');
+ await expect(notice).toContainText('What changes with Starter');
+ await expect(notice).toContainText('4 of 9 connectors paused (least used). You choose which.');
+ await expect(notice).toContainText('No inline code (code_execute).');
+ const cta = page.getByRole('button', { name: 'Subscribe to Starter' });
+ await expect(cta).toBeEnabled();
+ // The subscribe button comes first; the notice sits under it.
+ const [ctaBox, noticeBox] = await Promise.all([cta.boundingBox(), notice.boundingBox()]);
+ expect(ctaBox!.y).toBeLessThan(noticeBox!.y);
+
+ await notice.getByRole('button', { name: 'See which ones' }).click();
+ const details = page.getByTestId('plan-fit-details');
+ await expect(details).toContainText('Odoo');
+ await expect(details).toContainText('Paused: Etsy');
+ await expect(details).not.toContainText('calls');
+ await expect(details).toContainText('Trello');
+
+ if (process.env.PLAN_FIT_SHOT) {
+ await page.waitForTimeout(400);
+ await page.screenshot({ path: `${process.env.PLAN_FIT_SHOT}/wall-plan-fit.png`, fullPage: true });
+ }
+
+ // Team is one click away.
+ await notice.getByRole('button', { name: 'Choose Team instead' }).click();
+ await expect(page.getByRole('radio', { name: /Team/ })).toHaveAttribute('aria-checked', 'true');
+
+ const actions = events.filter((e) => e.event === 'paywall_action').map((e) => e.metadata?.action);
+ expect(actions).toContain('plan_fit_shown');
+ expect(actions).toContain('plan_fit_details_opened');
+ const shown = events.find((e) => e.metadata?.action === 'plan_fit_shown');
+ expect(shown?.metadata).toMatchObject({ source: 'trial_ended_wall', plan: 'starter' });
+ });
+
+ test('without a plan-fit answer the wall shows no block', async ({ page }) => {
+ await cloudSession(page, { license: ENDED, usage: TRIAL_USAGE, planFit: null, wall: { variant: 'trust', recap: null } });
+ await page.goto('/connectors');
+ await expect(page.getByRole('heading', { name: 'Keep your AI connected to your apps' })).toBeVisible({ timeout: 15_000 });
+ await page.getByRole('radio', { name: /Starter/ }).click();
+ await expect(page.getByTestId('plan-fit-notice')).toHaveCount(0);
+ });
+});
+
+test.describe('swap at the plan cap (cloud)', () => {
+ test('a connector paused by the plan is marked, and activating it at the cap opens the swap', async ({ page }) => {
+ const active = NINE.slice(0, 5).map((name) => ({
+ id: `${name.toLowerCase()}-id`,
+ name,
+ type: 'REST',
+ isActive: true,
+ baseUrl: `https://${name.toLowerCase()}.example.test`,
+ tools: [],
+ }));
+ const paused = { id: 'etsy-id', name: 'Etsy', type: 'REST', isActive: false, baseUrl: 'https://etsy.example.test', tools: [] };
+ const fit = starterFit({ connectors: ranked(NINE.slice(0, 5), 5, CALLS) });
+ const { updates } = await cloudSession(page, {
+ license: { plan: 'starter', status: 'active', expiresAt: null, features: {} },
+ usage: {
+ plan: 'starter',
+ connectors: { current: 5, max: 5, isOver: false },
+ mcpServers: { current: 1, max: 3, isOver: false },
+ users: { current: 1, max: 1, isOver: false },
+ isOverAny: false,
+ pausedByPlan: { connectors: ['etsy-id'], mcpServers: [] },
+ },
+ planFit: { starter: fit },
+ connectors: [...active, paused],
+ });
+ await page.goto('/connectors');
+ await expect(page.getByTestId('usage-banner-paused')).toContainText('1 connector is paused by your plan.', { timeout: 15_000 });
+ const card = page.locator('div.group', { hasText: 'Etsy' });
+ await expect(card.getByTestId('paused-by-plan')).toHaveText('Paused: plan limit');
+ await card.hover();
+ await card.getByRole('button', { name: 'Activate' }).click();
+
+ const dialog = page.getByTestId('plan-swap-dialog');
+ await expect(dialog).toContainText('Your plan includes 5 active connectors');
+ await expect(dialog).toContainText('Pause one to activate Etsy.');
+ // Most used first; the least used is preselected.
+ const options = dialog.getByRole('radio');
+ await expect(options).toHaveCount(5);
+ await expect(options.first()).toContainText('Odoo');
+ await expect(options.last()).toHaveAttribute('aria-checked', 'true');
+ await expect(dialog.getByRole('link', { name: 'Upgrade to Team' })).toBeVisible();
+
+ if (process.env.PLAN_FIT_SHOT) {
+ await page.waitForTimeout(300);
+ await page.screenshot({ path: `${process.env.PLAN_FIT_SHOT}/swap-dialog.png` });
+ }
+
+ await dialog.getByRole('button', { name: 'Pause and activate' }).click();
+ await expect(dialog).toHaveCount(0);
+ expect(updates).toEqual([
+ { id: 'slack-id', body: { isActive: false } },
+ { id: 'etsy-id', body: { isActive: true } },
+ ]);
+ });
+});
+
+test.describe('plan fit elsewhere (cloud)', () => {
+ test('Settings → License shows usage meters against the plan, and what Starter changes', async ({ page }) => {
+ const { events } = await cloudSession(page, {
+ license: { plan: 'trial', status: 'active', expiresAt: new Date(Date.now() + 2 * 86_400_000).toISOString(), trialDaysLeft: 2, features: {} },
+ usage: TRIAL_USAGE,
+ planFit: { starter: starterFit(), team: teamFit() },
+ });
+ await page.goto('/settings/license');
+ const meters = page.getByTestId('plan-usage');
+ await expect(meters).toContainText('Usage in your trial (Team limits)', { timeout: 15_000 });
+ await expect(meters.getByRole('meter', { name: 'Connectors' })).toHaveAttribute('aria-valuenow', '9');
+ await expect(meters).toContainText('9 / 15');
+ await expect(meters).toContainText('1,200 / 2,000');
+ await meters.getByRole('button', { name: 'See what changes with Starter' }).click();
+ await expect(meters.getByTestId('plan-fit-notice')).toContainText('What changes with Starter');
+ // The trial banner, in the last 3 days, links to the same block.
+ await expect(page.getByText('Your trial includes everything in Team.')).toBeVisible();
+ await page.getByRole('button', { name: 'Above Starter: see what changes' }).click();
+ await expect(page.getByRole('dialog')).toContainText('4 of 9 connectors paused');
+ if (process.env.PLAN_FIT_SHOT) {
+ await page.waitForTimeout(300);
+ await page.screenshot({ path: `${process.env.PLAN_FIT_SHOT}/trial-banner-dialog.png` });
+ await page.keyboard.press('Escape');
+ await page.waitForTimeout(300);
+ await meters.scrollIntoViewIfNeeded();
+ await meters.screenshot({ path: `${process.env.PLAN_FIT_SHOT}/license-meters.png` });
+ }
+ const sources = events.filter((e) => e.metadata?.action === 'plan_fit_shown').map((e) => e.metadata?.source);
+ expect(sources).toEqual(expect.arrayContaining(['license_page', 'trial_banner']));
+ });
+
+ test('the reactivation page gives counts only, in its own words', async ({ page }) => {
+ const anon = (fit: PlanFit): PlanFit => {
+ const strip = (r: PlanFit['connectors']) => ({
+ ...r,
+ keep: r.keep.map((x, i) => ({ id: `keep-${i}`, name: '', calls30d: x.calls30d })),
+ pause: r.pause.map((x, i) => ({ id: `pause-${i}`, name: '', calls30d: x.calls30d })),
+ });
+ return { ...fit, connectors: strip(fit.connectors), mcpServers: strip(fit.mcpServers), fileSearch: { ...fit.fileSearch, connectors: strip(fit.fileSearch.connectors) } };
+ };
+ const events: Array> = [];
+ await page.route(/\/(api|health)\//, async (route) => {
+ const req = route.request();
+ const p = new URL(req.url()).pathname;
+ const json = (body: unknown, status = 200) =>
+ route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) });
+ if (p === '/health/server-info') {
+ return json({ deploymentMode: 'cloud', mcpAuthMode: 'oauth2', hasUsers: true, registrationEnabled: true, ssoProviders: [] });
+ }
+ if (p.endsWith('/event') && req.method() === 'POST') {
+ events.push(req.postDataJSON());
+ return route.fulfill({ status: 204 });
+ }
+ if (p.startsWith('/api/license/reactivate/')) {
+ return json({
+ state: 'trial-ended',
+ workspaceName: 'Acme Logistics',
+ trialEndedAt: '2026-11-04T12:00:00.000Z',
+ client: 'Claude',
+ usage: { connectors: 9, mcpServers: 4, users: 2 },
+ planFit: { starter: anon(starterFit()), team: anon(teamFit()) },
+ });
+ }
+ if (p.endsWith('/users/me')) return json({ message: 'Unauthorized' }, 401);
+ return json({});
+ });
+ await page.goto('/reactivate/tok_abc');
+ await expect(page.getByRole('heading', { name: 'Pick up where Claude left off' })).toBeVisible({ timeout: 15_000 });
+ await expect(page.getByTestId('plan-fit-fits')).toHaveText('Everything you use today fits Team.');
+ await page.getByRole('radio', { name: /Starter/ }).click();
+ const notice = page.getByTestId('plan-fit-notice');
+ await expect(notice).toContainText('4 of 9 connectors paused (least used). You choose which after signing in.');
+ const cta = page.getByRole('button', { name: /Subscribe to Starter/ });
+ const [ctaBox, noticeBox] = await Promise.all([cta.boundingBox(), notice.boundingBox()]);
+ expect(ctaBox!.y).toBeLessThan(noticeBox!.y);
+ await expect(notice.getByRole('button', { name: 'See which ones' })).toHaveCount(0);
+ if (process.env.PLAN_FIT_SHOT) {
+ await page.waitForTimeout(300);
+ await page.screenshot({ path: `${process.env.PLAN_FIT_SHOT}/reactivate-plan-fit.png`, fullPage: true });
+ }
+ expect(events).toEqual(expect.arrayContaining([expect.objectContaining({ action: 'plan_fit_shown', plan: 'starter' })]));
+ });
+});
diff --git a/packages/frontend/tests/e2e/signup-trust-panel.spec.ts b/packages/frontend/tests/e2e/signup-trust-panel.spec.ts
index efca35f69..7c75b8a2a 100644
--- a/packages/frontend/tests/e2e/signup-trust-panel.spec.ts
+++ b/packages/frontend/tests/e2e/signup-trust-panel.spec.ts
@@ -35,7 +35,7 @@ test('cloud sign-up shows the numbers, never below their floors', async ({ page
await expect(page.getByRole('heading', { name: 'Create your free account' })).toBeVisible();
const panel = page.getByRole('complementary', { name: 'Why teams build on AnythingMCP' });
await expect(panel.getByText('200,000+')).toBeVisible();
- await expect(panel.getByText('1M+')).toBeVisible();
+ await expect(panel.getByText('2M+')).toBeVisible();
await expect(panel.getByText('3,800+')).toBeVisible();
await expect(panel.getByText(/stars on GitHub/)).toHaveCount(0);
await expect(panel.getByText('Listed in the Claude Directory')).toBeVisible();