From a6c85f9691c37504d5b85c67bebfbf265259e75e Mon Sep 17 00:00:00 2001 From: "helpcode.ai" Date: Mon, 9 Nov 2026 09:43:17 +0100 Subject: [PATCH 1/2] Show what Starter changes before checkout, and swap at the plan cap --- packages/frontend/messages/de.json | 78 ++++ packages/frontend/messages/en.json | 78 ++++ packages/frontend/messages/it.json | 78 ++++ .../frontend/src/app/connectors/[id]/page.tsx | 90 +++- .../frontend/src/app/connectors/new/page.tsx | 13 +- packages/frontend/src/app/connectors/page.tsx | 75 ++- .../src/app/connectors/setup/[slug]/page.tsx | 10 +- .../frontend/src/app/mcp-server/[id]/page.tsx | 47 +- packages/frontend/src/app/mcp-server/page.tsx | 38 +- .../src/app/reactivate/[token]/copy.ts | 158 +++++++ .../src/app/reactivate/[token]/page.tsx | 35 +- .../src/app/settings/license/page.tsx | 4 + .../frontend/src/app/settings/users/page.tsx | 13 + .../frontend/src/app/start-trial/page.tsx | 43 +- .../frontend/src/components/license-wall.tsx | 31 +- .../src/components/plan-fit-notice.tsx | 193 ++++++++ .../frontend/src/components/plan-picker.tsx | 18 + .../src/components/plan-swap-dialog.tsx | 341 ++++++++++++++ .../src/components/plan-usage-meters.tsx | 151 ++++++ .../frontend/src/components/trial-banner.tsx | 70 ++- .../frontend/src/components/usage-banner.tsx | 67 ++- packages/frontend/src/lib/api.ts | 120 ++++- packages/frontend/src/lib/card-trial.ts | 96 ++++ packages/frontend/src/lib/paywall-tracking.ts | 6 +- packages/frontend/src/lib/use-plan-fit.ts | 91 ++++ packages/frontend/tests/e2e/plan-fit.spec.ts | 442 ++++++++++++++++++ 26 files changed, 2323 insertions(+), 63 deletions(-) create mode 100644 packages/frontend/src/components/plan-fit-notice.tsx create mode 100644 packages/frontend/src/components/plan-swap-dialog.tsx create mode 100644 packages/frontend/src/components/plan-usage-meters.tsx create mode 100644 packages/frontend/src/lib/use-plan-fit.ts create mode 100644 packages/frontend/tests/e2e/plan-fit.spec.ts diff --git a/packages/frontend/messages/de.json b/packages/frontend/messages/de.json index 6e973c2e7..748487c4d 100644 --- a/packages/frontend/messages/de.json +++ b/packages/frontend/messages/de.json @@ -3605,5 +3605,83 @@ "directory": "Gelistet im Claude Directory", "directoryChatgpt": "Gelistet im ChatGPT-Plugin-Verzeichnis", "directoryBoth": "Gelistet in den Verzeichnissen von Claude und ChatGPT" + }, + "planFit": { + "title": "Was sich mit {plan} ändert", + "fits": "Alles, was Sie heute nutzen, passt in {plan}.", + "chooseTeam": "Stattdessen Team wählen", + "showDetails": "Welche genau?", + "hideDetails": "Liste ausblenden", + "keep": "Bleiben aktiv", + "pause": "Würden pausiert", + "groups": { + "connectors": "Connectors", + "mcpServers": "MCP-Server", + "fileSearch": "File-Search-Ordner" + }, + "calls": "{count, plural, =0 {keine Aufrufe in 30 Tagen} one {# Aufruf in 30 Tagen} other {# Aufrufe in 30 Tagen}}", + "unnamed": "Ohne Namen", + "lines": { + "connectors": "{paused} Ihrer {total} Connectors würden pausiert: die am wenigsten genutzten. Welche, wählen Sie jederzeit selbst.", + "mcpServers": "{paused} Ihrer {total} MCP-Server würden pausiert: die am wenigsten genutzten.", + "mcpServersChoose": "{paused} Ihrer {total} MCP-Server würden pausiert: die am wenigsten genutzten. Welche, wählen Sie jederzeit selbst.", + "users": "{plan} enthält {max, plural, one {# Benutzer} other {# Benutzer}}; dieser Workspace hat {current}. Niemand verliert den Zugang, aber Sie können niemanden mehr einladen.", + "fileSearch": "File Search: {maxFolders, plural, one {# Ordner} other {# Ordner}} und {maxFiles, number} Dateien (Sie haben {folders, plural, one {# Ordner} other {# Ordner}}, {files, number} Dateien). Darüber pausiert die Synchronisierung.", + "fileSearchStorage": "File Search: {maxSize} Speicher (Sie nutzen {size}). Darüber pausiert die Synchronisierung.", + "codeTools": "Gespeicherte Code-Tools: {max} enthalten (Sie haben {current}). Bestehende funktionieren weiter; neue können Sie nicht hinzufügen.", + "inline": "Inline-Code (code_execute) ist nicht enthalten." + } + }, + "planSwap": { + "badge": "Pausiert: Tarif-Limit", + "activate": "Aktivieren", + "title": { + "connectors": "Ihr Tarif enthält {max, plural, one {# aktiven Connector} other {# aktive Connectors}}", + "mcpServers": "Ihr Tarif enthält {max, plural, one {# aktiven MCP-Server} other {# aktive MCP-Server}}", + "fileSearchConnectors": "Ihr Tarif enthält {max, plural, one {# File-Search-Ordner} other {# File-Search-Ordner}}" + }, + "body": "Pausieren Sie einen, um {name} zu aktivieren.", + "bodyCreate": "Pausieren Sie einen, um einen neuen hinzuzufügen, oder wechseln Sie den Tarif.", + "listLabel": "Jetzt aktiv, meistgenutzte zuerst", + "listLabelPlain": "Jetzt aktiv", + "calls": "{count, plural, =0 {keine Aufrufe in 30 Tagen} one {# Aufruf in 30 Tagen} other {# Aufrufe in 30 Tagen}}", + "pauseAndActivate": "Pausieren und aktivieren", + "pauseOnly": "Pausieren", + "working": "Wird umgestellt…", + "cancel": "Abbrechen", + "upgradeTeam": "Auf Team upgraden", + "askEnterprise": "Enterprise anfragen", + "loading": "Wird geladen…", + "empty": "Nichts Aktives zum Pausieren.", + "error": "Umstellen nicht möglich: {message}", + "pausedDone": "{name} ist pausiert. Sie können den neuen jetzt hinzufügen.", + "notice": { + "connectors": "Ihr Tarif enthält {max, plural, one {# aktiven Connector} other {# aktive Connectors}}. Pausieren Sie einen oder wechseln Sie den Tarif.", + "mcpServers": "Ihr Tarif enthält {max, plural, one {# aktiven MCP-Server} other {# aktive MCP-Server}}. Pausieren Sie einen oder wechseln Sie den Tarif.", + "fileSearchConnectors": "Ihr Tarif enthält {max, plural, one {# File-Search-Ordner} other {# File-Search-Ordner}}. Pausieren Sie einen oder wechseln Sie den Tarif.", + "users": "Ihr Tarif enthält {max, plural, one {# Benutzer} other {# Benutzer}}. Für weitere Einladungen wechseln Sie den Tarif." + }, + "pauseOne": "Einen pausieren" + }, + "planUsage": { + "title": "Nutzung im Tarif {plan}", + "titleTrial": "Nutzung in Ihrer Testphase (Team-Limits)", + "connectors": "Connectors", + "mcpServers": "MCP-Server", + "users": "Benutzer", + "fileSearchFolders": "File-Search-Ordner", + "fileSearchFiles": "File-Search-Dateien", + "fileSearchStorage": "File-Search-Speicher", + "codeTools": "Gespeicherte Code-Tools", + "codeRuns": "Code-Ausführungen diesen Monat", + "inlineCode": "Inline-Code", + "included": "Enthalten", + "notIncluded": "Nicht enthalten", + "inUseNotIncluded": "In Nutzung, nicht enthalten", + "unlimited": "{current} · ohne Limit", + "paused": "{count} durch Ihren Tarif pausiert", + "starterTitle": "Mit Starter", + "seeStarter": "Was sich mit Starter ändert", + "hideStarter": "Ausblenden" } } diff --git a/packages/frontend/messages/en.json b/packages/frontend/messages/en.json index 6f5d672c8..dc239fc36 100644 --- a/packages/frontend/messages/en.json +++ b/packages/frontend/messages/en.json @@ -3605,5 +3605,83 @@ "directory": "Listed in the Claude Directory", "directoryChatgpt": "Listed in the ChatGPT Plugin Directory", "directoryBoth": "Listed in the Claude and ChatGPT directories" + }, + "planFit": { + "title": "What changes with {plan}", + "fits": "Everything you use today fits {plan}.", + "chooseTeam": "Choose Team instead", + "showDetails": "See which ones", + "hideDetails": "Hide the list", + "keep": "Stay active", + "pause": "Would be paused", + "groups": { + "connectors": "Connectors", + "mcpServers": "MCP servers", + "fileSearch": "File Search folders" + }, + "calls": "{count, plural, =0 {no calls in 30 days} one {# call in 30 days} other {# calls in 30 days}}", + "unnamed": "Unnamed", + "lines": { + "connectors": "{paused} of your {total} connectors would be paused: the least used. You choose which ones at any time.", + "mcpServers": "{paused} of your {total} MCP servers would be paused: the least used.", + "mcpServersChoose": "{paused} of your {total} MCP servers would be paused: the least used. You choose which ones at any time.", + "users": "{plan} includes {max, plural, one {# user} other {# users}}; this workspace has {current}. Nobody loses access, but you cannot invite more.", + "fileSearch": "File Search: {maxFolders, plural, one {# folder} other {# folders}} and {maxFiles, number} files (you have {folders, plural, one {# folder} other {# folders}}, {files, number} files). Syncing pauses above that.", + "fileSearchStorage": "File Search: {maxSize} of storage (you have {size}). Syncing pauses above that.", + "codeTools": "Saved code tools: {max} included (you have {current}). Existing ones keep working; you cannot add more.", + "inline": "Inline code (code_execute) is not included." + } + }, + "planSwap": { + "badge": "Paused: plan limit", + "activate": "Activate", + "title": { + "connectors": "Your plan includes {max, plural, one {# active connector} other {# active connectors}}", + "mcpServers": "Your plan includes {max, plural, one {# active MCP server} other {# active MCP servers}}", + "fileSearchConnectors": "Your plan includes {max, plural, one {# File Search folder} other {# File Search folders}}" + }, + "body": "Pause one to activate {name}.", + "bodyCreate": "Pause one to add a new one, or upgrade.", + "listLabel": "Active now, most used first", + "listLabelPlain": "Active now", + "calls": "{count, plural, =0 {no calls in 30 days} one {# call in 30 days} other {# calls in 30 days}}", + "pauseAndActivate": "Pause and activate", + "pauseOnly": "Pause", + "working": "Switching…", + "cancel": "Cancel", + "upgradeTeam": "Upgrade to Team", + "askEnterprise": "Ask about Enterprise", + "loading": "Loading…", + "empty": "Nothing active to pause.", + "error": "Could not switch: {message}", + "pausedDone": "{name} is paused. You can add the new one now.", + "notice": { + "connectors": "Your plan includes {max, plural, one {# active connector} other {# active connectors}}. Pause one or upgrade.", + "mcpServers": "Your plan includes {max, plural, one {# active MCP server} other {# active MCP servers}}. Pause one or upgrade.", + "fileSearchConnectors": "Your plan includes {max, plural, one {# File Search folder} other {# File Search folders}}. Pause one or upgrade.", + "users": "Your plan includes {max, plural, one {# user} other {# users}}. Upgrade to invite more." + }, + "pauseOne": "Pause one" + }, + "planUsage": { + "title": "Usage on {plan}", + "titleTrial": "Usage in your trial (Team limits)", + "connectors": "Connectors", + "mcpServers": "MCP servers", + "users": "Users", + "fileSearchFolders": "File Search folders", + "fileSearchFiles": "File Search files", + "fileSearchStorage": "File Search storage", + "codeTools": "Saved code tools", + "codeRuns": "Code runs this month", + "inlineCode": "Inline code", + "included": "Included", + "notIncluded": "Not included", + "inUseNotIncluded": "In use, not included", + "unlimited": "{current} · no limit", + "paused": "{count} paused by your plan", + "starterTitle": "On Starter", + "seeStarter": "See what changes with Starter", + "hideStarter": "Hide" } } diff --git a/packages/frontend/messages/it.json b/packages/frontend/messages/it.json index b3352e0af..b8c638d30 100644 --- a/packages/frontend/messages/it.json +++ b/packages/frontend/messages/it.json @@ -3605,5 +3605,83 @@ "directory": "Presente nella Claude Directory", "directoryChatgpt": "Presente nella directory dei plugin di ChatGPT", "directoryBoth": "Presente nelle directory di Claude e ChatGPT" + }, + "planFit": { + "title": "Cosa cambia con {plan}", + "fits": "Tutto ciò che usi oggi rientra in {plan}.", + "chooseTeam": "Scegli Team", + "showDetails": "Vedi quali", + "hideDetails": "Nascondi l'elenco", + "keep": "Restano attivi", + "pause": "Verrebbero messi in pausa", + "groups": { + "connectors": "Connettori", + "mcpServers": "Server MCP", + "fileSearch": "Cartelle File Search" + }, + "calls": "{count, plural, =0 {nessuna chiamata in 30 giorni} one {# chiamata in 30 giorni} other {# chiamate in 30 giorni}}", + "unnamed": "Senza nome", + "lines": { + "connectors": "{paused} dei tuoi {total} connettori verrebbero messi in pausa: i meno usati. Scegli tu quali, quando vuoi.", + "mcpServers": "{paused} dei tuoi {total} server MCP verrebbero messi in pausa: i meno usati.", + "mcpServersChoose": "{paused} dei tuoi {total} server MCP verrebbero messi in pausa: i meno usati. Scegli tu quali, quando vuoi.", + "users": "{plan} include {max, plural, one {# utente} other {# utenti}}; questo workspace ne ha {current}. Nessuno perde l'accesso, ma non puoi invitarne altri.", + "fileSearch": "File Search: {maxFolders, plural, one {# cartella} other {# cartelle}} e {maxFiles, number} file (ne hai {folders, plural, one {# cartella} other {# cartelle}}, {files, number} file). Oltre, la sincronizzazione si ferma.", + "fileSearchStorage": "File Search: {maxSize} di spazio (ne usi {size}). Oltre, la sincronizzazione si ferma.", + "codeTools": "Code tool salvati: {max} inclusi (ne hai {current}). Quelli esistenti continuano a funzionare; non puoi aggiungerne altri.", + "inline": "Il codice inline (code_execute) non è incluso." + } + }, + "planSwap": { + "badge": "In pausa: limite del piano", + "activate": "Attiva", + "title": { + "connectors": "Il tuo piano include {max, plural, one {# connettore attivo} other {# connettori attivi}}", + "mcpServers": "Il tuo piano include {max, plural, one {# server MCP attivo} other {# server MCP attivi}}", + "fileSearchConnectors": "Il tuo piano include {max, plural, one {# cartella File Search} other {# cartelle File Search}}" + }, + "body": "Mettine uno in pausa per attivare {name}.", + "bodyCreate": "Mettine uno in pausa per aggiungerne uno nuovo, oppure cambia piano.", + "listLabel": "Attivi ora, i più usati per primi", + "listLabelPlain": "Attivi ora", + "calls": "{count, plural, =0 {nessuna chiamata in 30 giorni} one {# chiamata in 30 giorni} other {# chiamate in 30 giorni}}", + "pauseAndActivate": "Metti in pausa e attiva", + "pauseOnly": "Metti in pausa", + "working": "Cambio in corso…", + "cancel": "Annulla", + "upgradeTeam": "Passa a Team", + "askEnterprise": "Chiedi di Enterprise", + "loading": "Caricamento…", + "empty": "Niente di attivo da mettere in pausa.", + "error": "Impossibile cambiare: {message}", + "pausedDone": "{name} è in pausa. Ora puoi aggiungere quello nuovo.", + "notice": { + "connectors": "Il tuo piano include {max, plural, one {# connettore attivo} other {# connettori attivi}}. Mettine uno in pausa o cambia piano.", + "mcpServers": "Il tuo piano include {max, plural, one {# server MCP attivo} other {# server MCP attivi}}. Mettine uno in pausa o cambia piano.", + "fileSearchConnectors": "Il tuo piano include {max, plural, one {# cartella File Search} other {# cartelle File Search}}. Mettine una in pausa o cambia piano.", + "users": "Il tuo piano include {max, plural, one {# utente} other {# utenti}}. Cambia piano per invitarne altri." + }, + "pauseOne": "Mettine uno in pausa" + }, + "planUsage": { + "title": "Utilizzo con {plan}", + "titleTrial": "Utilizzo nella prova (limiti di Team)", + "connectors": "Connettori", + "mcpServers": "Server MCP", + "users": "Utenti", + "fileSearchFolders": "Cartelle File Search", + "fileSearchFiles": "File in File Search", + "fileSearchStorage": "Spazio File Search", + "codeTools": "Code tool salvati", + "codeRuns": "Esecuzioni di codice questo mese", + "inlineCode": "Codice inline", + "included": "Incluso", + "notIncluded": "Non incluso", + "inUseNotIncluded": "In uso, non incluso", + "unlimited": "{current} · nessun limite", + "paused": "{count} in pausa per il piano", + "starterTitle": "Con Starter", + "seeStarter": "Cosa cambia con Starter", + "hideStarter": "Nascondi" } } diff --git a/packages/frontend/src/app/connectors/[id]/page.tsx b/packages/frontend/src/app/connectors/[id]/page.tsx index d6f3870a4..f65d74748 100644 --- a/packages/frontend/src/app/connectors/[id]/page.tsx +++ b/packages/frontend/src/app/connectors/[id]/page.tsx @@ -3,7 +3,9 @@ import { useEffect, useRef, useState, type ReactNode } from 'react'; import { useParams, useRouter, useSearchParams } from 'next/navigation'; import { useAuth } from '@/lib/auth-context'; -import { connectors, tools, withSecretsNotice, withImportNotice, isMachineGrant, type ToolTestResult, type CatalogDiff, type OAuthGrant } from '@/lib/api'; +import { connectors, tools, withSecretsNotice, withImportNotice, isMachineGrant, planLimitOf, type ToolTestResult, type CatalogDiff, type OAuthGrant } from '@/lib/api'; +import { atPlanCap, pausedByPlanSets, usePlanUsage } from '@/lib/use-plan-fit'; +import { PausedByPlanBadge, PlanSwapDialog, type SwapAxis } from '@/components/plan-swap-dialog'; import { findDemoByTool } from '@/lib/demo-connectors'; import { ToolEditor } from '@/components/tool-editor'; import { McpAssignModal } from '@/components/mcp-assign-modal'; @@ -186,6 +188,12 @@ export default function ConnectorDetailPage() { const router = useRouter(); const searchParams = useSearchParams(); const id = params.id as string; + // Cloud: whether the last automatic downgrade paused this connector, and the + // swap dialog when activating it would go past the plan. + const { usage: planUsage, reload: reloadPlanUsage } = usePlanUsage(); + const pausedByPlan = pausedByPlanSets(planUsage).connectors.has(id); + const tSwap = useTranslations('planSwap'); + const [swap, setSwap] = useState<{ axis: SwapAxis; max: number | null; activate?: () => Promise } | null>(null); const [connector, setConnector] = useState(null); const [toolList, setToolList] = useState([]); @@ -518,8 +526,39 @@ export default function ConnectorDetailPage() { } }; - const handleSave = async () => { + /** The plan-limit axis of this connector: File Search folders have their own. */ + const swapAxis = (): SwapAxis => (connector?.type === 'FILE_SEARCH' ? 'fileSearchConnectors' : 'connectors'); + + /** Activate from the header (a connector the plan paused); the swap at the cap. */ + const handleActivateNow = async () => { + if (!token || !connector) return; + if (swapAxis() === 'connectors' && atPlanCap(planUsage, 'connectors')) { + setSwap({ axis: 'connectors', max: planUsage?.connectors.max ?? null }); + return; + } + try { + await connectors.update(id, { isActive: true }, token); + fetchConnector(); + reloadPlanUsage(); + } catch (err: any) { + const limit = planLimitOf(err); + if (limit && limit.axis !== 'users') setSwap({ axis: limit.axis, max: limit.max || null }); + else setMsg(t('msg.error', { message: err.message })); + } + }; + + /** + * Save the form. Turning the connector on at the plan's cap opens the swap + * dialog first; its "Pause and activate" then saves again (`rethrow`, so a + * failure puts the paused one back). + */ + const handleSave = async (opts?: { rethrow?: boolean }) => { if (!token) return; + const activating = editActive && !connector.isActive; + if (!opts?.rethrow && activating && swapAxis() === 'connectors' && atPlanCap(planUsage, 'connectors')) { + setSwap({ axis: 'connectors', max: planUsage?.connectors.max ?? null, activate: () => handleSave({ rethrow: true }) }); + return; + } try { if ( editAuthType === 'OAUTH2' && @@ -627,7 +666,14 @@ export default function ConnectorDetailPage() { setMsg(withSecretsNotice(t('msg.updated'), saved)); setEditing(false); fetchConnector(); + if (activating) reloadPlanUsage(); } catch (err: any) { + if (opts?.rethrow) throw err; + const limit = planLimitOf(err); + if (limit && limit.axis !== 'users') { + setSwap({ axis: limit.axis, max: limit.max || null, activate: () => handleSave({ rethrow: true }) }); + return; + } setMsg(t('msg.error', { message: err.message })); } }; @@ -1108,6 +1154,23 @@ export default function ConnectorDetailPage() { } > + {swap && ( + { + if (!open) setSwap(null); + }} + axis={swap.axis} + target={{ id, name: connector.name }} + max={swap.max} + activate={swap.activate} + onDone={() => { + fetchConnector(); + reloadPlanUsage(); + }} + /> + )} + { if (!open && !deleting) setDeleteTarget(null); }}> @@ -1144,12 +1207,21 @@ export default function ConnectorDetailPage() {
{connector.name} {connector.type} - - {connector.isActive ? t('active') : t('inactive')} - + {!connector.isActive && pausedByPlan ? ( + <> + + + + ) : ( + + {connector.isActive ? t('active') : t('inactive')} + + )}
@@ -1777,7 +1849,7 @@ export default function ConnectorDetailPage() { {t('details.instructionsHint')}

- diff --git a/packages/frontend/src/app/connectors/new/page.tsx b/packages/frontend/src/app/connectors/new/page.tsx index f165b693e..1e5f765fd 100644 --- a/packages/frontend/src/app/connectors/new/page.tsx +++ b/packages/frontend/src/app/connectors/new/page.tsx @@ -5,7 +5,8 @@ import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { useTranslations } from 'next-intl'; import { useAuth } from '@/lib/auth-context'; -import { adapters, connectors, isMachineGrant, type CatalogUrlMatch, type OAuthGrant } from '@/lib/api'; +import { adapters, connectors, isMachineGrant, planLimitOf, type CatalogUrlMatch, type OAuthGrant, type PlanLimitError } from '@/lib/api'; +import { PlanLimitNotice } from '@/components/plan-swap-dialog'; import { ConnectorLogo } from '@/components/connector-logo'; import { AppShell } from '@/components/app-shell'; import { Card } from '@/components/ui/card'; @@ -111,6 +112,8 @@ export default function NewConnectorPage() { // EMAIL: servers, login and sender (IMAP + SMTP). const [emailForm, setEmailForm] = useState(EMPTY_EMAIL_FORM); const [error, setError] = useState(''); + // A create refused at the plan's limit (Cloud): "Pause one or upgrade". + const [planLimit, setPlanLimit] = useState(null); const [loading, setLoading] = useState(false); const [testResult, setTestResult] = useState<{ ok: boolean; @@ -235,6 +238,7 @@ export default function NewConnectorPage() { e.preventDefault(); if (!token || !selectedType) return; setError(''); + setPlanLimit(null); if ( authType === 'OAUTH2' && selectedType !== 'MCP' && @@ -299,6 +303,7 @@ export default function NewConnectorPage() { setCreatedConnector({ id: created.id, name: name || created.name }); } } catch (err: any) { + setPlanLimit(planLimitOf(err)); setError(err.message); } finally { setLoading(false); @@ -428,7 +433,11 @@ export default function NewConnectorPage() { {t('configure', { type: t(`types.${selectedType}.name`) })} - {error && isTrialLimitMessage(error) ? ( + {planLimit ? ( +
+ +
+ ) : error && isTrialLimitMessage(error) ? (
diff --git a/packages/frontend/src/app/connectors/page.tsx b/packages/frontend/src/app/connectors/page.tsx index aa361430f..c3063b671 100644 --- a/packages/frontend/src/app/connectors/page.tsx +++ b/packages/frontend/src/app/connectors/page.tsx @@ -3,7 +3,9 @@ import Link from 'next/link'; import { useEffect, useState, useCallback } from 'react'; import { useAuth } from '@/lib/auth-context'; -import { connectors, withSecretsNotice, withImportNotice } from '@/lib/api'; +import { connectors, planLimitOf, withSecretsNotice, withImportNotice } from '@/lib/api'; +import { atPlanCap, pausedByPlanSets, usePlanUsage } from '@/lib/use-plan-fit'; +import { PausedByPlanBadge, PlanSwapDialog, type SwapAxis } from '@/components/plan-swap-dialog'; import * as Dialog from '@radix-ui/react-dialog'; import { useTranslations } from 'next-intl'; import { AppSelect } from '@/components/ui/select'; @@ -114,12 +116,49 @@ export default function ConnectorsPage() { const [checkingHealth, setCheckingHealth] = useState(false); const [deleteConfirm, setDeleteConfirm] = useState<{ id: string; name: string } | null>(null); const [deleting, setDeleting] = useState(false); + // Cloud: which connectors the last automatic downgrade paused, and the swap + // dialog when activating one would go past the plan. + const { usage, reload: reloadUsage } = usePlanUsage(); + const pausedByPlan = pausedByPlanSets(usage).connectors; + const tSwap = useTranslations('planSwap'); + const [swap, setSwap] = useState<{ id: string; name: string; axis: SwapAxis; max: number | null } | null>(null); + const [activating, setActivating] = useState(null); useEffect(() => { if (!token) return; connectors.list(token).then(setList).catch(() => {}).finally(() => setLoading(false)); }, [token]); + const refreshAfterSwap = useCallback(() => { + if (!token) return; + connectors.list(token).then(setList).catch(() => {}); + reloadUsage(); + }, [token, reloadUsage]); + + /** Activate a connector the plan paused; at the plan's cap, offer the swap. */ + const handleActivate = async (c: { id: string; name: string; type: string }) => { + if (!token) return; + const axis: SwapAxis = c.type === 'FILE_SEARCH' ? 'fileSearchConnectors' : 'connectors'; + if (axis === 'connectors' && atPlanCap(usage, 'connectors')) { + setSwap({ id: c.id, name: c.name, axis, max: usage?.connectors.max ?? null }); + return; + } + setActivating(c.id); + try { + await connectors.update(c.id, { isActive: true }, token); + refreshAfterSwap(); + } catch (err) { + const limit = planLimitOf(err); + if (limit && limit.axis !== 'users') setSwap({ id: c.id, name: c.name, axis: limit.axis, max: limit.max || null }); + else { + setMsg((err as Error)?.message || String(err)); + setTimeout(() => setMsg(''), 5000); + } + } finally { + setActivating(null); + } + }; + const handleDelete = useCallback(async () => { if (!token || !deleteConfirm) return; setDeleting(true); @@ -327,6 +366,19 @@ export default function ConnectorsPage() {
{/* Delete Confirmation Dialog */} + {swap && ( + { + if (!open) setSwap(null); + }} + axis={swap.axis} + target={{ id: swap.id, name: swap.name }} + max={swap.max} + onDone={refreshAfterSwap} + /> + )} + { if (!open) setDeleteConfirm(null); }}> @@ -509,17 +561,26 @@ export default function ConnectorsPage() {
{authTypeLabel(c.authType)}
- - {c.isActive ? t('card.active') : t('card.inactive')} - + {!c.isActive && pausedByPlan.has(c.id) ? ( + + ) : ( + + {c.isActive ? t('card.active') : t('card.inactive')} + + )} {/* Hover actions (above the full-card link) */}
+ {!c.isActive && pausedByPlan.has(c.id) && ( + + )} {(c.type === 'REST' || c.type === 'GRAPHQL' || c.type === 'SOAP') && (
)} {error && ( - isTrialLimitMessage(error) ? ( + planLimit && planLimit.message === error ? ( + + ) : isTrialLimitMessage(error) ? ( ) : (

{error}

diff --git a/packages/frontend/src/app/mcp-server/[id]/page.tsx b/packages/frontend/src/app/mcp-server/[id]/page.tsx index 6a39dc2de..03db8f71f 100644 --- a/packages/frontend/src/app/mcp-server/[id]/page.tsx +++ b/packages/frontend/src/app/mcp-server/[id]/page.tsx @@ -5,7 +5,9 @@ import { useParams, useRouter } from 'next/navigation'; import Link from 'next/link'; import { useTranslations } from 'next-intl'; import { useAuth } from '@/lib/auth-context'; -import { mcpServers, connectors as connectorsApi, mcpKeys, productEvents, server as serverApi } from '@/lib/api'; +import { mcpServers, connectors as connectorsApi, mcpKeys, productEvents, server as serverApi, planLimitOf } from '@/lib/api'; +import { atPlanCap, pausedByPlanSets, usePlanUsage } from '@/lib/use-plan-fit'; +import { PausedByPlanBadge, PlanSwapDialog } from '@/components/plan-swap-dialog'; import { AppShell } from '@/components/app-shell'; import { Button, buttonVariants } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; @@ -33,6 +35,9 @@ export default function McpServerDetailPage() { const isCloud = deploymentMode === 'cloud'; const router = useRouter(); const t = useTranslations('mcpServer'); + // Cloud: paused by the last automatic downgrade, and the swap at the cap. + const { usage: planUsage, reload: reloadPlanUsage } = usePlanUsage(); + const [swapMax, setSwapMax] = useState(undefined); const [server, setServer] = useState(null); // Bumped a moment after the first request arrives, so the card-trial offer @@ -144,10 +149,20 @@ export default function McpServerDetailPage() { const handleToggleActive = async () => { if (!token || !id || !server) return; + const activating = !server.isActive; + // Turning one on at the plan's cap: pause another first (swap dialog). + if (activating && atPlanCap(planUsage, 'mcpServers')) { + setSwapMax(planUsage?.mcpServers.max ?? null); + return; + } try { - const updated = await mcpServers.update(id, { isActive: !server.isActive }, token); + const updated = await mcpServers.update(id, { isActive: activating }, token); setServer((prev: any) => ({ ...prev, ...updated })); - } catch {} + reloadPlanUsage(); + } catch (err) { + const limit = planLimitOf(err); + if (limit?.axis === 'mcpServers') setSwapMax(limit.max || null); + } }; const saveAssignments = (next: Set) => { @@ -733,6 +748,22 @@ Auth: OAuth`; } > + {swapMax !== undefined && ( + { + if (!open) setSwapMax(undefined); + }} + axis="mcpServers" + target={{ id: server.id, name: server.name }} + max={swapMax} + onDone={() => { + setServer((prev: any) => ({ ...prev, isActive: true })); + reloadPlanUsage(); + }} + /> + )} + {/* Server identity header */}
@@ -745,9 +776,13 @@ Auth: OAuth`;
{server.name} - - {server.isActive ? t('active') : t('inactive')} - + {!server.isActive && pausedByPlanSets(planUsage).mcpServers.has(server.id) ? ( + + ) : ( + + {server.isActive ? t('active') : t('inactive')} + + )}
{endpointUrl}
diff --git a/packages/frontend/src/app/mcp-server/page.tsx b/packages/frontend/src/app/mcp-server/page.tsx index 0ab2790cb..86b15826d 100644 --- a/packages/frontend/src/app/mcp-server/page.tsx +++ b/packages/frontend/src/app/mcp-server/page.tsx @@ -4,7 +4,9 @@ import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; import { useTranslations } from 'next-intl'; import { useAuth } from '@/lib/auth-context'; -import { mcpServers } from '@/lib/api'; +import { mcpServers, planLimitOf, type PlanLimitError } from '@/lib/api'; +import { pausedByPlanSets, usePlanUsage } from '@/lib/use-plan-fit'; +import { PausedByPlanBadge, PlanLimitNotice } from '@/components/plan-swap-dialog'; import { AppShell } from '@/components/app-shell'; import { Card } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; @@ -29,6 +31,10 @@ export default function McpServerListPage() { const [search, setSearch] = useState(''); const [statusFilter, setStatusFilter] = useState(''); const [copiedId, setCopiedId] = useState(null); + // A create refused at the plan's limit (Cloud): "Pause one or upgrade". + const [createLimit, setCreateLimit] = useState(null); + const { usage, reload: reloadUsage } = usePlanUsage(); + const pausedByPlan = pausedByPlanSets(usage).mcpServers; useEffect(() => { if (!token) return; @@ -39,6 +45,7 @@ export default function McpServerListPage() { if (!token || !newName.trim()) return; setCreating(true); setCreateError(''); + setCreateLimit(null); try { const server = await mcpServers.create( { name: newName.trim(), description: newDescription.trim() || undefined }, @@ -51,6 +58,7 @@ export default function McpServerListPage() { router.push(`/mcp-server/${server.id}`); } catch (err: any) { // Used to be swallowed: a failed create left the button doing nothing. + setCreateLimit(planLimitOf(err)); setCreateError(err?.message || t('createError')); } finally { setCreating(false); @@ -110,7 +118,15 @@ export default function McpServerListPage() { className="w-full rounded-[9px] border border-[var(--border)] bg-[var(--surface)] px-3 py-2 text-sm text-[var(--text)] outline-none focus:border-[var(--brand)]" />
- {createError && isTrialLimitMessage(createError) ? ( + {createLimit ? ( + { + mcpServers.list(token!).then(setServers).catch(() => {}); + reloadUsage(); + }} + /> + ) : createError && isTrialLimitMessage(createError) ? ( ) : createError ? (
@@ -212,13 +228,17 @@ export default function McpServerListPage() {
- - {active ? t('active') : t('inactive')} - + {!active && pausedByPlan.has(s.id) ? ( + + ) : ( + + {active ? t('active') : t('inactive')} + + )} {s.description && ( diff --git a/packages/frontend/src/app/reactivate/[token]/copy.ts b/packages/frontend/src/app/reactivate/[token]/copy.ts index 4f367b298..b7a18de2c 100644 --- a/packages/frontend/src/app/reactivate/[token]/copy.ts +++ b/packages/frontend/src/app/reactivate/[token]/copy.ts @@ -666,3 +666,161 @@ export function splitTag(text: string, tag: string): [string, string, string] | const m = new RegExp(`^([\\s\\S]*)<${tag}>([\\s\\S]*)([\\s\\S]*)$`).exec(text); return m ? [m[1], m[2], m[3]] : null; } + +/** + * The "What changes with Starter" block on this page, in ICU message syntax + * (the PlanFitNotice component renders it through next-intl, under its own + * provider in the page's language). Same keys as `planFit` in the dashboard + * messages, worded for a visitor who is not signed in: counts only, and the + * choice of which ones is made after signing in. + */ +export interface PlanFitCopy { + title: string; + fits: string; + chooseTeam: string; + lines: { + connectors: string; + mcpServers: string; + mcpServersChoose: string; + users: string; + fileSearch: string; + fileSearchStorage: string; + codeTools: string; + inline: string; + }; +} + +export const PLAN_FIT_COPY: Record = { + en: { + title: 'What changes with {plan}', + fits: 'Everything you use today fits {plan}.', + chooseTeam: 'Choose Team instead', + lines: { + connectors: + '{paused} of your {total} connectors would be paused (the least used). You choose which ones after signing in.', + mcpServers: '{paused} of your {total} MCP servers would be paused (the least used).', + mcpServersChoose: + '{paused} of your {total} MCP servers would be paused (the least used). You choose which ones after signing in.', + users: + '{plan} includes {max, plural, one {# user} other {# users}}; this workspace has {current}. Nobody loses access, but you cannot invite more.', + fileSearch: + 'File Search: {maxFolders, plural, one {# folder} other {# folders}} and {maxFiles, number} files (you have {folders, plural, one {# folder} other {# folders}}, {files, number} files). Syncing pauses above that.', + fileSearchStorage: 'File Search: {maxSize} of storage (you have {size}). Syncing pauses above that.', + codeTools: 'Saved code tools: {max} included (you have {current}). Existing ones keep working; you cannot add more.', + inline: 'Inline code (code_execute) is not included.', + }, + }, + de: { + title: 'Was sich mit {plan} ändert', + fits: 'Alles, was Sie heute nutzen, passt in {plan}.', + chooseTeam: 'Stattdessen Team wählen', + lines: { + connectors: + '{paused} Ihrer {total} Connectors würden pausiert (die am wenigsten genutzten). Welche, wählen Sie nach der Anmeldung.', + mcpServers: '{paused} Ihrer {total} MCP-Server würden pausiert (die am wenigsten genutzten).', + mcpServersChoose: + '{paused} Ihrer {total} MCP-Server würden pausiert (die am wenigsten genutzten). Welche, wählen Sie nach der Anmeldung.', + users: + '{plan} enthält {max} Benutzer; dieser Workspace hat {current}. Niemand verliert den Zugang, aber Sie können niemanden mehr einladen.', + fileSearch: + 'File Search: {maxFolders} Ordner und {maxFiles, number} Dateien (Sie haben {folders} Ordner, {files, number} Dateien). Darüber pausiert die Synchronisierung.', + fileSearchStorage: 'File Search: {maxSize} Speicher (Sie nutzen {size}). Darüber pausiert die Synchronisierung.', + codeTools: + 'Gespeicherte Code-Tools: {max} enthalten (Sie haben {current}). Bestehende funktionieren weiter; neue können Sie nicht hinzufügen.', + inline: 'Inline-Code (code_execute) ist nicht enthalten.', + }, + }, + it: { + title: 'Cosa cambia con {plan}', + fits: 'Tutto ciò che usi oggi rientra in {plan}.', + chooseTeam: 'Scegli Team', + lines: { + connectors: + '{paused} dei tuoi {total} connettori verrebbero messi in pausa (i meno usati). Scegli tu quali dopo aver effettuato l’accesso.', + mcpServers: '{paused} dei tuoi {total} server MCP verrebbero messi in pausa (i meno usati).', + mcpServersChoose: + '{paused} dei tuoi {total} server MCP verrebbero messi in pausa (i meno usati). Scegli tu quali dopo aver effettuato l’accesso.', + users: + '{plan} include {max, plural, one {# utente} other {# utenti}}; questo workspace ne ha {current}. Nessuno perde l’accesso, ma non puoi invitarne altri.', + fileSearch: + 'File Search: {maxFolders, plural, one {# cartella} other {# cartelle}} e {maxFiles, number} file (ne hai {folders, plural, one {# cartella} other {# cartelle}}, {files, number} file). Oltre, la sincronizzazione si ferma.', + fileSearchStorage: 'File Search: {maxSize} di spazio (ne usi {size}). Oltre, la sincronizzazione si ferma.', + codeTools: + 'Code tool salvati: {max} inclusi (ne hai {current}). Quelli esistenti continuano a funzionare; non puoi aggiungerne altri.', + inline: 'Il codice inline (code_execute) non è incluso.', + }, + }, + es: { + title: 'Qué cambia con {plan}', + fits: 'Todo lo que usas hoy cabe en {plan}.', + chooseTeam: 'Elegir Team', + lines: { + connectors: + 'Se pausarían {paused} de tus {total} conectores (los menos usados). Eliges cuáles después de iniciar sesión.', + mcpServers: 'Se pausarían {paused} de tus {total} servidores MCP (los menos usados).', + mcpServersChoose: + 'Se pausarían {paused} de tus {total} servidores MCP (los menos usados). Eliges cuáles después de iniciar sesión.', + users: + '{plan} incluye {max, plural, one {# usuario} other {# usuarios}}; este workspace tiene {current}. Nadie pierde el acceso, pero no puedes invitar a más.', + fileSearch: + 'File Search: {maxFolders, plural, one {# carpeta} other {# carpetas}} y {maxFiles, number} archivos (tienes {folders, plural, one {# carpeta} other {# carpetas}}, {files, number} archivos). Por encima, la sincronización se detiene.', + fileSearchStorage: 'File Search: {maxSize} de almacenamiento (usas {size}). Por encima, la sincronización se detiene.', + codeTools: + 'Herramientas de código guardadas: {max} incluidas (tienes {current}). Las existentes siguen funcionando; no puedes añadir más.', + inline: 'El código inline (code_execute) no está incluido.', + }, + }, + zh: { + title: '选择 {plan} 后的变化', + fits: '你目前使用的一切都在 {plan} 范围内。', + chooseTeam: '改选 Team', + lines: { + connectors: '你的 {total} 个连接器中有 {paused} 个将被暂停(使用最少的)。登录后可自行选择。', + mcpServers: '你的 {total} 个 MCP 服务器中有 {paused} 个将被暂停(使用最少的)。', + mcpServersChoose: '你的 {total} 个 MCP 服务器中有 {paused} 个将被暂停(使用最少的)。登录后可自行选择。', + users: '{plan} 包含 {max} 个用户;此工作区有 {current} 个。没有人会失去访问权限,但无法再邀请更多人。', + fileSearch: + 'File Search:{maxFolders} 个文件夹和 {maxFiles, number} 个文件(你有 {folders} 个文件夹、{files, number} 个文件)。超出部分将暂停同步。', + fileSearchStorage: 'File Search:{maxSize} 存储空间(你已使用 {size})。超出部分将暂停同步。', + codeTools: '已保存的代码工具:包含 {max} 个(你有 {current} 个)。现有工具继续可用,但无法再添加。', + inline: '不包含内联代码(code_execute)。', + }, + }, + ja: { + title: '{plan} にすると変わること', + fits: '現在お使いのものはすべて {plan} に収まります。', + chooseTeam: '代わりに Team を選ぶ', + lines: { + connectors: '{total} 個のコネクタのうち {paused} 個が一時停止されます(利用の少ないもの)。どれにするかはサインイン後に選べます。', + mcpServers: '{total} 個の MCP サーバーのうち {paused} 個が一時停止されます(利用の少ないもの)。', + mcpServersChoose: + '{total} 個の MCP サーバーのうち {paused} 個が一時停止されます(利用の少ないもの)。どれにするかはサインイン後に選べます。', + users: '{plan} のユーザー数は {max} 人で、このワークスペースには {current} 人います。誰もアクセスを失いませんが、追加の招待はできません。', + fileSearch: + 'File Search:フォルダ {maxFolders} 個、ファイル {maxFiles, number} 件まで(現在フォルダ {folders} 個、ファイル {files, number} 件)。超えた分は同期が止まります。', + fileSearchStorage: 'File Search:ストレージ {maxSize}(現在 {size})。超えた分は同期が止まります。', + codeTools: '保存済みコードツール:{max} 個まで(現在 {current} 個)。既存のものは使えますが、追加はできません。', + inline: 'インラインコード(code_execute)は含まれません。', + }, + }, + ru: { + title: 'Что изменится на тарифе {plan}', + fits: 'Всё, чем вы пользуетесь сейчас, укладывается в {plan}.', + chooseTeam: 'Выбрать Team', + lines: { + connectors: + 'Будут приостановлены {paused} из {total} коннекторов (наименее используемые). Какие именно, вы выберете после входа.', + mcpServers: 'Будут приостановлены {paused} из {total} MCP-серверов (наименее используемые).', + mcpServersChoose: + 'Будут приостановлены {paused} из {total} MCP-серверов (наименее используемые). Какие именно, вы выберете после входа.', + users: + 'Пользователей в тарифе {plan}: {max}; в этом workspace: {current}. Никто не теряет доступ, но пригласить новых нельзя.', + fileSearch: + 'File Search: папок до {maxFolders}, файлов до {maxFiles, number} (у вас папок: {folders}, файлов: {files, number}). Сверх лимита синхронизация останавливается.', + fileSearchStorage: 'File Search: {maxSize} хранилища (у вас {size}). Сверх лимита синхронизация останавливается.', + codeTools: + 'Сохранённые инструменты кода: включено {max} (у вас {current}). Существующие продолжают работать; новые добавить нельзя.', + inline: 'Встроенный код (code_execute) не входит в тариф.', + }, + }, +}; diff --git a/packages/frontend/src/app/reactivate/[token]/page.tsx b/packages/frontend/src/app/reactivate/[token]/page.tsx index a389e334b..75c011dc5 100644 --- a/packages/frontend/src/app/reactivate/[token]/page.tsx +++ b/packages/frontend/src/app/reactivate/[token]/page.tsx @@ -3,6 +3,7 @@ import { Suspense, useEffect, useMemo, useRef, useState } from 'react'; import Link from 'next/link'; import { useParams, useSearchParams } from 'next/navigation'; +import { NextIntlClientProvider } from 'next-intl'; import { ApiError, reactivation, type ReactivationView } from '@/lib/api'; import { useAuth } from '@/lib/auth-context'; import { useTrustStats } from '@/lib/trust-stats'; @@ -11,18 +12,30 @@ import { CHATGPT_DIRECTORY_URL, CLAUDE_DIRECTORY_URL, buildEnterpriseContactUrl, import { CLOUD_PLANS, DEFAULT_SELECTION, + fitFor, formatEur, limitsExceeded, planById, + selectionForFit, selectionForUsage, yearlyPerMonth, type PlanSelection, } from '@/lib/card-trial'; import { LogoIcon } from '@/components/logo-icon'; +import { PlanFitNotice } from '@/components/plan-fit-notice'; import { buttonVariants } from '@/components/ui/button'; import { cn } from '@/lib/utils'; import { deviceKind, usePaywallLifecycle, type PaywallAction, type PaywallActionDetail } from '@/lib/paywall-tracking'; -import { COPY, DATE_LOCALE, fmt, pickPageLang, splitTag, type PageLang, type ReactivateCopy } from './copy'; +import { + COPY, + DATE_LOCALE, + PLAN_FIT_COPY, + fmt, + pickPageLang, + splitTag, + type PageLang, + type ReactivateCopy, +} from './copy'; /** How often the page asks whether the payment reached the workspace, and for how long. */ const PAID_POLL_MS = 4000; @@ -95,7 +108,9 @@ function ReactivateContent() { .then((v) => { if (cancelled) return; setView(v); - if (!picked.current) setSelection(selectionForUsage(DEFAULT_SELECTION, v.usage)); + if (!picked.current) { + setSelection(selectionForFit(selectionForUsage(DEFAULT_SELECTION, v.usage), v.planFit ?? null)); + } }) .catch((e) => { if (cancelled) return; @@ -267,6 +282,22 @@ function ReactivateContent() { offer={view.offer ?? null} onEnterprise={() => track('enterprise_clicked')} /> + {/* 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 + } + /> + + {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..6f5af2150 100644 --- a/packages/frontend/src/app/start-trial/page.tsx +++ b/packages/frontend/src/app/start-trial/page.tsx @@ -4,7 +4,7 @@ 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'; @@ -19,6 +19,7 @@ import { readAuthorizationReturn, readPlanIntent, readPromoCode, + selectionForFit, takeAuthorizationReturn, trialRunning, writeCardTrialPrompt, @@ -91,6 +92,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 +139,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 +159,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,8 +285,14 @@ export default function StartTrialPage() { { + if (token) { + productEvents.track('paywall_action', token, { source, action, plan, device: deviceKind() }); + } + }} />
diff --git a/packages/frontend/src/components/license-wall.tsx b/packages/frontend/src/components/license-wall.tsx index 98cb5281a..20e9cde10 100644 --- a/packages/frontend/src/components/license-wall.tsx +++ b/packages/frontend/src/components/license-wall.tsx @@ -4,7 +4,7 @@ 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'; @@ -16,6 +16,7 @@ import { DEFAULT_SELECTION, planById, readPlanIntent, + selectionForFit, selectionForUsage, type PlanSelection, type WorkspaceUsage, @@ -49,6 +50,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 +114,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 () => { @@ -381,6 +406,8 @@ export function LicenseWall() { compact disabled={checkout.loading} usage={usage} + planFit={planFit} + onPlanFitEvent={(event, plan) => trackWall(event, { plan })} />
+ )} + {onChooseTeam && fit.plan !== 'team' && ( + + )} +
+ )} + + {open && groups.length > 0 && ( +
+ {groups.map(([key, ranked]) => ( + + ))} +
+ )} + + ); +} + +function FitGroup({ title, ranked }: { title: string; ranked: FitRanked }) { + const t = useTranslations('planFit'); + return ( +
+
{title}
+
+ + +
+
+ ); +} + +function FitList({ label, items, tone }: { label: string; items: FitItem[]; tone: 'keep' | 'pause' }) { + const t = useTranslations('planFit'); + if (items.length === 0) return null; + return ( +
+
+ + {label} +
+
    + {items.map((item) => ( +
  • + + {item.name || t('unnamed')} + + + {t('calls', { count: item.calls30d })} + +
  • + ))} +
+
+ ); +} diff --git a/packages/frontend/src/components/plan-picker.tsx b/packages/frontend/src/components/plan-picker.tsx index 6a23e47bd..958a8c089 100644 --- a/packages/frontend/src/components/plan-picker.tsx +++ b/packages/frontend/src/components/plan-picker.tsx @@ -3,6 +3,7 @@ import { CLOUD_PLANS, describeUsage, + fitFor, formatEur, limitsExceeded, smallestFittingPlan, @@ -12,7 +13,9 @@ import { type PlanSelection, type WorkspaceUsage, } from '@/lib/card-trial'; +import type { PlanFits } from '@/lib/api'; import { buildEnterpriseContactUrl } from '@/lib/marketing'; +import { PlanFitNotice, type PlanFitEvent } from '@/components/plan-fit-notice'; import { useAuth } from '@/lib/auth-context'; import { formatLocal, useLocalPricing } from '@/lib/local-currency'; import { cn } from '@/lib/utils'; @@ -66,6 +69,9 @@ function PeriodToggle({ * and a line to Enterprise for anything beyond them. `compact` lays the plans * out as rows (for the licence wall's narrow modal). With `usage` (what the * workspace already has), a plan it has outgrown says so under its summary. + * With `planFit` (Cloud), the selected plan is followed by what it changes + * for this workspace ("What changes with Starter"), or by one quiet line when + * Team fits; Starter stays selectable either way. */ export function PlanPicker({ value, @@ -73,12 +79,16 @@ export function PlanPicker({ compact = false, disabled = false, usage = null, + planFit = null, + onPlanFitEvent, }: { value: PlanSelection; onChange: (next: PlanSelection) => void; compact?: boolean; disabled?: boolean; usage?: WorkspaceUsage | null; + planFit?: PlanFits | null; + onPlanFitEvent?: (event: PlanFitEvent, plan: PlanSelection['plan']) => void; }) { const { deploymentMode } = useAuth(); // Outside the euro area Stripe charges in the visitor's currency: lead with @@ -176,6 +186,14 @@ export function PlanPicker({ ); })} + 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 ? ( +
+ ); + } + + 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/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..afc26a960 --- /dev/null +++ b/packages/frontend/tests/e2e/plan-fit.spec.ts @@ -0,0 +1,442 @@ +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 your 9 connectors would be paused: the least used. You choose which ones at any time.', + '1 of your 4 MCP servers would be paused: the least used.', + 'Starter includes 1 user; this workspace has 2. Nobody loses access, but you cannot invite more.', + 'File Search: 1 folder and 500 files (you have 2 folders, 1,200 files). Syncing pauses above that.', + 'Saved code tools: 5 included (you have 7). Existing ones keep working; you cannot add more.', + 'Inline code (code_execute) is not included.', + ]); + }); + + 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 your 4 MCP servers would be paused: the least used. You choose which ones at any time.'); + }); + + 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: 500 MB of storage (you have 1.2 GB). Syncing pauses above that.', + ]); + }); + + 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 your 9 connectors would be paused (the least used). You choose which ones 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 your 9 connectors would be paused: the least used. You choose which ones at any time.'); + await expect(notice).toContainText('Inline code (code_execute) is not included.'); + await expect(page.getByRole('button', { name: 'Subscribe to Starter' })).toBeEnabled(); + + 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('420 calls in 30 days'); + 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 your 9 connectors would be 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 your 9 connectors would be paused (the least used). You choose which ones after signing in.', + ); + 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' })])); + }); +}); From e6a89411d39f2ad8f7f365b45bdcff81ca78d7ef Mon Sep 17 00:00:00 2001 From: "helpcode.ai" Date: Mon, 9 Nov 2026 10:01:34 +0100 Subject: [PATCH 2/2] Put the plan-fit notice under the subscribe button, compact it, show 2M+ --- .../src/public-stats/trust-stats.format.ts | 4 +- .../src/public-stats/trust-stats.spec.ts | 4 +- .../src/settings/email-templates.spec.ts | 2 +- .../src/settings/email.service.spec.ts | 2 +- packages/frontend/messages/de.json | 27 ++-- packages/frontend/messages/en.json | 27 ++-- packages/frontend/messages/it.json | 27 ++-- .../src/app/reactivate/[token]/copy.ts | 151 +++++++----------- .../src/app/reactivate/[token]/page.tsx | 32 ++-- .../frontend/src/app/start-trial/page.tsx | 20 ++- .../frontend/src/components/license-wall.tsx | 12 +- .../src/components/plan-fit-notice.tsx | 64 ++------ .../frontend/src/components/plan-picker.tsx | 18 +-- packages/frontend/src/lib/trust-stats.ts | 2 +- packages/frontend/tests/e2e/plan-fit.spec.ts | 40 +++-- .../tests/e2e/signup-trust-panel.spec.ts | 2 +- 16 files changed, 197 insertions(+), 237 deletions(-) diff --git a/packages/backend/src/public-stats/trust-stats.format.ts b/packages/backend/src/public-stats/trust-stats.format.ts index 8d5bbf92c..b56916cda 100644 --- a/packages/backend/src/public-stats/trust-stats.format.ts +++ b/packages/backend/src/public-stats/trust-stats.format.ts @@ -29,10 +29,10 @@ export interface TrustStatsDisplay { /** * The least each figure is shown as (Matteo, 8 Oct 2026): 200,000+ downloads - * across all distribution channels, 1M+ AI tool calls last month. Docker Hub alone undercounts downloads, so the floor applies even + * across all distribution channels, 2M+ AI tool calls last month (raised from 1M+ on 9 Nov 2026, 2.08M in the last 30 days). Docker Hub alone undercounts downloads, so the floor applies even * when a live number is missing or lower. */ -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: number | null | undefined, floor: number) => (usable(n) && n > floor ? n : floor); diff --git a/packages/backend/src/public-stats/trust-stats.spec.ts b/packages/backend/src/public-stats/trust-stats.spec.ts index e60df72c2..a4eb18daf 100644 --- a/packages/backend/src/public-stats/trust-stats.spec.ts +++ b/packages/backend/src/public-stats/trust-stats.spec.ts @@ -35,11 +35,11 @@ describe('trust number formatting (always rounded down)', () => { it('formats a whole set, never below the floors', () => { expect( formatTrustStats({ githubStars: 984, dockerPulls: 32_456, workspaces: 3_871, toolCalls30d: 654_321, updatedAt: null }), - ).toEqual({ downloads: '200,000+', workspaces: '3,800+', toolCalls: '1M+' }); + ).toEqual({ downloads: '200,000+', workspaces: '3,800+', toolCalls: '2M+' }); expect( formatTrustStats({ githubStars: 1_234, dockerPulls: 250_400, workspaces: null, toolCalls30d: 2_100_000, updatedAt: null }), ).toEqual({ downloads: '250,000+', workspaces: null, toolCalls: '2M+' }); - expect(formatTrustStats(null)).toEqual({ downloads: '200,000+', workspaces: null, toolCalls: '1M+' }); + expect(formatTrustStats(null)).toEqual({ downloads: '200,000+', workspaces: null, toolCalls: '2M+' }); }); }); diff --git a/packages/backend/src/settings/email-templates.spec.ts b/packages/backend/src/settings/email-templates.spec.ts index 972c5af8a..a45028632 100644 --- a/packages/backend/src/settings/email-templates.spec.ts +++ b/packages/backend/src/settings/email-templates.spec.ts @@ -139,7 +139,7 @@ describe('trust band', () => { it('shows the numbers, never below their floors', () => { const html = verify.render(cloud, { name: 'A', other: 'B' }).html; expect(html).toContain('200,000+'); - expect(html).toContain('1M+'); + expect(html).toContain('2M+'); expect(html).toContain('3,800+'); expect(html).toContain('workspaces on AnythingMCP Cloud'); expect(html).not.toContain('stars on GitHub'); diff --git a/packages/backend/src/settings/email.service.spec.ts b/packages/backend/src/settings/email.service.spec.ts index 9dadfd516..f6b86e48f 100644 --- a/packages/backend/src/settings/email.service.spec.ts +++ b/packages/backend/src/settings/email.service.spec.ts @@ -81,7 +81,7 @@ describe('EmailService', () => { // Downloads and tool calls at their floors; no GitHub star count. expect(mail.html).not.toContain('stars on GitHub'); expect(mail.html).toContain('200,000+'); - expect(mail.html).toContain('1M+'); + expect(mail.html).toContain('2M+'); }); describe('Reply-To of the system SMTP', () => { diff --git a/packages/frontend/messages/de.json b/packages/frontend/messages/de.json index 748487c4d..84721aafa 100644 --- a/packages/frontend/messages/de.json +++ b/packages/frontend/messages/de.json @@ -483,7 +483,11 @@ "title": "Tool-Leistung", "description": "Aufrufe, Fehler und Latenz nach Tool, Connector und MCP-Server. Für diese Tabelle gilt ein eigener Zeitraum.", "range": "Zeitraum der Tool-Leistung", - "ranges": { "24": "Letzte 24 Stunden", "168": "Letzte 7 Tage", "720": "Letzte 30 Tage" }, + "ranges": { + "24": "Letzte 24 Stunden", + "168": "Letzte 7 Tage", + "720": "Letzte 30 Tage" + }, "connector": "Tool-Leistung nach Connector filtern", "server": "Tool-Leistung nach MCP-Server filtern", "allConnectors": "Alle Connectors", @@ -3611,25 +3615,24 @@ "fits": "Alles, was Sie heute nutzen, passt in {plan}.", "chooseTeam": "Stattdessen Team wählen", "showDetails": "Welche genau?", - "hideDetails": "Liste ausblenden", + "hideDetails": "Ausblenden", "keep": "Bleiben aktiv", - "pause": "Würden pausiert", + "pause": "Pausiert", "groups": { "connectors": "Connectors", "mcpServers": "MCP-Server", "fileSearch": "File-Search-Ordner" }, - "calls": "{count, plural, =0 {keine Aufrufe in 30 Tagen} one {# Aufruf in 30 Tagen} other {# Aufrufe in 30 Tagen}}", "unnamed": "Ohne Namen", "lines": { - "connectors": "{paused} Ihrer {total} Connectors würden pausiert: die am wenigsten genutzten. Welche, wählen Sie jederzeit selbst.", - "mcpServers": "{paused} Ihrer {total} MCP-Server würden pausiert: die am wenigsten genutzten.", - "mcpServersChoose": "{paused} Ihrer {total} MCP-Server würden pausiert: die am wenigsten genutzten. Welche, wählen Sie jederzeit selbst.", - "users": "{plan} enthält {max, plural, one {# Benutzer} other {# Benutzer}}; dieser Workspace hat {current}. Niemand verliert den Zugang, aber Sie können niemanden mehr einladen.", - "fileSearch": "File Search: {maxFolders, plural, one {# Ordner} other {# Ordner}} und {maxFiles, number} Dateien (Sie haben {folders, plural, one {# Ordner} other {# Ordner}}, {files, number} Dateien). Darüber pausiert die Synchronisierung.", - "fileSearchStorage": "File Search: {maxSize} Speicher (Sie nutzen {size}). Darüber pausiert die Synchronisierung.", - "codeTools": "Gespeicherte Code-Tools: {max} enthalten (Sie haben {current}). Bestehende funktionieren weiter; neue können Sie nicht hinzufügen.", - "inline": "Inline-Code (code_execute) ist nicht enthalten." + "connectors": "{paused} von {total} Connectors pausiert (am wenigsten genutzt). Welche, wählen Sie selbst.", + "mcpServers": "{paused} von {total} MCP-Servern pausiert (am wenigsten genutzt).", + "mcpServersChoose": "{paused} von {total} MCP-Servern pausiert (am wenigsten genutzt). Welche, wählen Sie selbst.", + "users": "{max, plural, one {# Nutzer} other {# Nutzer}} enthalten, Sie haben {current}. Niemand verliert den Zugang.", + "fileSearch": "File Search: bis zu {maxFolders, plural, one {# Ordner} other {# Ordner}} und {maxFiles, number} Dateien.", + "fileSearchStorage": "File Search: bis zu {maxSize} Speicher.", + "codeTools": "{max} gespeicherte Code-Tools (Sie haben {current}).", + "inline": "Kein Inline-Code (code_execute)." } }, "planSwap": { diff --git a/packages/frontend/messages/en.json b/packages/frontend/messages/en.json index dc239fc36..dad71b395 100644 --- a/packages/frontend/messages/en.json +++ b/packages/frontend/messages/en.json @@ -483,7 +483,11 @@ "title": "Tool performance", "description": "Calls, errors and latency by tool, connector and MCP server. This table has its own time range.", "range": "Tool performance time range", - "ranges": { "24": "Last 24 hours", "168": "Last 7 days", "720": "Last 30 days" }, + "ranges": { + "24": "Last 24 hours", + "168": "Last 7 days", + "720": "Last 30 days" + }, "connector": "Filter tool performance by connector", "server": "Filter tool performance by MCP server", "allConnectors": "All connectors", @@ -3611,25 +3615,24 @@ "fits": "Everything you use today fits {plan}.", "chooseTeam": "Choose Team instead", "showDetails": "See which ones", - "hideDetails": "Hide the list", + "hideDetails": "Hide", "keep": "Stay active", - "pause": "Would be paused", + "pause": "Paused", "groups": { "connectors": "Connectors", "mcpServers": "MCP servers", "fileSearch": "File Search folders" }, - "calls": "{count, plural, =0 {no calls in 30 days} one {# call in 30 days} other {# calls in 30 days}}", "unnamed": "Unnamed", "lines": { - "connectors": "{paused} of your {total} connectors would be paused: the least used. You choose which ones at any time.", - "mcpServers": "{paused} of your {total} MCP servers would be paused: the least used.", - "mcpServersChoose": "{paused} of your {total} MCP servers would be paused: the least used. You choose which ones at any time.", - "users": "{plan} includes {max, plural, one {# user} other {# users}}; this workspace has {current}. Nobody loses access, but you cannot invite more.", - "fileSearch": "File Search: {maxFolders, plural, one {# folder} other {# folders}} and {maxFiles, number} files (you have {folders, plural, one {# folder} other {# folders}}, {files, number} files). Syncing pauses above that.", - "fileSearchStorage": "File Search: {maxSize} of storage (you have {size}). Syncing pauses above that.", - "codeTools": "Saved code tools: {max} included (you have {current}). Existing ones keep working; you cannot add more.", - "inline": "Inline code (code_execute) is not included." + "connectors": "{paused} of {total} connectors paused (least used). You choose which.", + "mcpServers": "{paused} of {total} MCP servers paused (least used).", + "mcpServersChoose": "{paused} of {total} MCP servers paused (least used). You choose which.", + "users": "{max, plural, one {# user} other {# users}} included, you have {current}. Nobody loses access.", + "fileSearch": "File Search: up to {maxFolders, plural, one {# folder} other {# folders}} and {maxFiles, number} files.", + "fileSearchStorage": "File Search: up to {maxSize} of storage.", + "codeTools": "{max} saved code tools (you have {current}).", + "inline": "No inline code (code_execute)." } }, "planSwap": { diff --git a/packages/frontend/messages/it.json b/packages/frontend/messages/it.json index b8c638d30..d6813a674 100644 --- a/packages/frontend/messages/it.json +++ b/packages/frontend/messages/it.json @@ -483,7 +483,11 @@ "title": "Prestazioni degli strumenti", "description": "Chiamate, errori e latenza per strumento, connettore e server MCP. Questa tabella ha un proprio intervallo di tempo.", "range": "Periodo delle prestazioni degli strumenti", - "ranges": { "24": "Ultime 24 ore", "168": "Ultimi 7 giorni", "720": "Ultimi 30 giorni" }, + "ranges": { + "24": "Ultime 24 ore", + "168": "Ultimi 7 giorni", + "720": "Ultimi 30 giorni" + }, "connector": "Filtra le prestazioni per connettore", "server": "Filtra le prestazioni per server MCP", "allConnectors": "Tutti i connettori", @@ -3611,25 +3615,24 @@ "fits": "Tutto ciò che usi oggi rientra in {plan}.", "chooseTeam": "Scegli Team", "showDetails": "Vedi quali", - "hideDetails": "Nascondi l'elenco", + "hideDetails": "Nascondi", "keep": "Restano attivi", - "pause": "Verrebbero messi in pausa", + "pause": "In pausa", "groups": { "connectors": "Connettori", "mcpServers": "Server MCP", "fileSearch": "Cartelle File Search" }, - "calls": "{count, plural, =0 {nessuna chiamata in 30 giorni} one {# chiamata in 30 giorni} other {# chiamate in 30 giorni}}", "unnamed": "Senza nome", "lines": { - "connectors": "{paused} dei tuoi {total} connettori verrebbero messi in pausa: i meno usati. Scegli tu quali, quando vuoi.", - "mcpServers": "{paused} dei tuoi {total} server MCP verrebbero messi in pausa: i meno usati.", - "mcpServersChoose": "{paused} dei tuoi {total} server MCP verrebbero messi in pausa: i meno usati. Scegli tu quali, quando vuoi.", - "users": "{plan} include {max, plural, one {# utente} other {# utenti}}; questo workspace ne ha {current}. Nessuno perde l'accesso, ma non puoi invitarne altri.", - "fileSearch": "File Search: {maxFolders, plural, one {# cartella} other {# cartelle}} e {maxFiles, number} file (ne hai {folders, plural, one {# cartella} other {# cartelle}}, {files, number} file). Oltre, la sincronizzazione si ferma.", - "fileSearchStorage": "File Search: {maxSize} di spazio (ne usi {size}). Oltre, la sincronizzazione si ferma.", - "codeTools": "Code tool salvati: {max} inclusi (ne hai {current}). Quelli esistenti continuano a funzionare; non puoi aggiungerne altri.", - "inline": "Il codice inline (code_execute) non è incluso." + "connectors": "{paused} connettori su {total} in pausa (i meno usati). Scegli tu quali.", + "mcpServers": "{paused} server MCP su {total} in pausa (i meno usati).", + "mcpServersChoose": "{paused} server MCP su {total} in pausa (i meno usati). Scegli tu quali.", + "users": "{max, plural, one {# utente incluso} other {# utenti inclusi}}, ne hai {current}. Nessuno perde l’accesso.", + "fileSearch": "File Search: fino a {maxFolders, plural, one {# cartella} other {# cartelle}} e {maxFiles, number} file.", + "fileSearchStorage": "File Search: fino a {maxSize} di spazio.", + "codeTools": "{max} code tool salvati (ne hai {current}).", + "inline": "Niente codice inline (code_execute)." } }, "planSwap": { diff --git a/packages/frontend/src/app/reactivate/[token]/copy.ts b/packages/frontend/src/app/reactivate/[token]/copy.ts index b7a18de2c..a690af99f 100644 --- a/packages/frontend/src/app/reactivate/[token]/copy.ts +++ b/packages/frontend/src/app/reactivate/[token]/copy.ts @@ -696,18 +696,14 @@ export const PLAN_FIT_COPY: Record = { fits: 'Everything you use today fits {plan}.', chooseTeam: 'Choose Team instead', lines: { - connectors: - '{paused} of your {total} connectors would be paused (the least used). You choose which ones after signing in.', - mcpServers: '{paused} of your {total} MCP servers would be paused (the least used).', - mcpServersChoose: - '{paused} of your {total} MCP servers would be paused (the least used). You choose which ones after signing in.', - users: - '{plan} includes {max, plural, one {# user} other {# users}}; this workspace has {current}. Nobody loses access, but you cannot invite more.', - fileSearch: - 'File Search: {maxFolders, plural, one {# folder} other {# folders}} and {maxFiles, number} files (you have {folders, plural, one {# folder} other {# folders}}, {files, number} files). Syncing pauses above that.', - fileSearchStorage: 'File Search: {maxSize} of storage (you have {size}). Syncing pauses above that.', - codeTools: 'Saved code tools: {max} included (you have {current}). Existing ones keep working; you cannot add more.', - inline: 'Inline code (code_execute) is not included.', + connectors: '{paused} of {total} connectors paused (least used). You choose which after signing in.', + mcpServers: '{paused} of {total} MCP servers paused (least used).', + mcpServersChoose: '{paused} of {total} MCP servers paused (least used). You choose which after signing in.', + users: '{max, plural, one {# user} other {# users}} included, you have {current}. Nobody loses access.', + fileSearch: 'File Search: up to {maxFolders, plural, one {# folder} other {# folders}} and {maxFiles, number} files.', + fileSearchStorage: 'File Search: up to {maxSize} of storage.', + codeTools: '{max} saved code tools (you have {current}).', + inline: 'No inline code (code_execute).', }, }, de: { @@ -715,19 +711,14 @@ export const PLAN_FIT_COPY: Record = { fits: 'Alles, was Sie heute nutzen, passt in {plan}.', chooseTeam: 'Stattdessen Team wählen', lines: { - connectors: - '{paused} Ihrer {total} Connectors würden pausiert (die am wenigsten genutzten). Welche, wählen Sie nach der Anmeldung.', - mcpServers: '{paused} Ihrer {total} MCP-Server würden pausiert (die am wenigsten genutzten).', - mcpServersChoose: - '{paused} Ihrer {total} MCP-Server würden pausiert (die am wenigsten genutzten). Welche, wählen Sie nach der Anmeldung.', - users: - '{plan} enthält {max} Benutzer; dieser Workspace hat {current}. Niemand verliert den Zugang, aber Sie können niemanden mehr einladen.', - fileSearch: - 'File Search: {maxFolders} Ordner und {maxFiles, number} Dateien (Sie haben {folders} Ordner, {files, number} Dateien). Darüber pausiert die Synchronisierung.', - fileSearchStorage: 'File Search: {maxSize} Speicher (Sie nutzen {size}). Darüber pausiert die Synchronisierung.', - codeTools: - 'Gespeicherte Code-Tools: {max} enthalten (Sie haben {current}). Bestehende funktionieren weiter; neue können Sie nicht hinzufügen.', - inline: 'Inline-Code (code_execute) ist nicht enthalten.', + connectors: '{paused} von {total} Connectors pausiert (am wenigsten genutzt). Welche, wählen Sie nach der Anmeldung.', + mcpServers: '{paused} von {total} MCP-Servern pausiert (am wenigsten genutzt).', + mcpServersChoose: '{paused} von {total} MCP-Servern pausiert (am wenigsten genutzt). Welche, wählen Sie nach der Anmeldung.', + users: '{max, plural, one {# Nutzer} other {# Nutzer}} enthalten, Sie haben {current}. Niemand verliert den Zugang.', + fileSearch: 'File Search: bis zu {maxFolders, plural, one {# Ordner} other {# Ordner}} und {maxFiles, number} Dateien.', + fileSearchStorage: 'File Search: bis zu {maxSize} Speicher.', + codeTools: '{max} gespeicherte Code-Tools (Sie haben {current}).', + inline: 'Kein Inline-Code (code_execute).', }, }, it: { @@ -735,19 +726,14 @@ export const PLAN_FIT_COPY: Record = { fits: 'Tutto ciò che usi oggi rientra in {plan}.', chooseTeam: 'Scegli Team', lines: { - connectors: - '{paused} dei tuoi {total} connettori verrebbero messi in pausa (i meno usati). Scegli tu quali dopo aver effettuato l’accesso.', - mcpServers: '{paused} dei tuoi {total} server MCP verrebbero messi in pausa (i meno usati).', - mcpServersChoose: - '{paused} dei tuoi {total} server MCP verrebbero messi in pausa (i meno usati). Scegli tu quali dopo aver effettuato l’accesso.', - users: - '{plan} include {max, plural, one {# utente} other {# utenti}}; questo workspace ne ha {current}. Nessuno perde l’accesso, ma non puoi invitarne altri.', - fileSearch: - 'File Search: {maxFolders, plural, one {# cartella} other {# cartelle}} e {maxFiles, number} file (ne hai {folders, plural, one {# cartella} other {# cartelle}}, {files, number} file). Oltre, la sincronizzazione si ferma.', - fileSearchStorage: 'File Search: {maxSize} di spazio (ne usi {size}). Oltre, la sincronizzazione si ferma.', - codeTools: - 'Code tool salvati: {max} inclusi (ne hai {current}). Quelli esistenti continuano a funzionare; non puoi aggiungerne altri.', - inline: 'Il codice inline (code_execute) non è incluso.', + connectors: '{paused} connettori su {total} in pausa (i meno usati). Scegli tu quali dopo l’accesso.', + mcpServers: '{paused} server MCP su {total} in pausa (i meno usati).', + mcpServersChoose: '{paused} server MCP su {total} in pausa (i meno usati). Scegli tu quali dopo l’accesso.', + users: '{max, plural, one {# utente incluso} other {# utenti inclusi}}, ne hai {current}. Nessuno perde l’accesso.', + fileSearch: 'File Search: fino a {maxFolders, plural, one {# cartella} other {# cartelle}} e {maxFiles, number} file.', + fileSearchStorage: 'File Search: fino a {maxSize} di spazio.', + codeTools: '{max} code tool salvati (ne hai {current}).', + inline: 'Niente codice inline (code_execute).', }, }, es: { @@ -755,72 +741,59 @@ export const PLAN_FIT_COPY: Record = { fits: 'Todo lo que usas hoy cabe en {plan}.', chooseTeam: 'Elegir Team', lines: { - connectors: - 'Se pausarían {paused} de tus {total} conectores (los menos usados). Eliges cuáles después de iniciar sesión.', - mcpServers: 'Se pausarían {paused} de tus {total} servidores MCP (los menos usados).', - mcpServersChoose: - 'Se pausarían {paused} de tus {total} servidores MCP (los menos usados). Eliges cuáles después de iniciar sesión.', - users: - '{plan} incluye {max, plural, one {# usuario} other {# usuarios}}; este workspace tiene {current}. Nadie pierde el acceso, pero no puedes invitar a más.', - fileSearch: - 'File Search: {maxFolders, plural, one {# carpeta} other {# carpetas}} y {maxFiles, number} archivos (tienes {folders, plural, one {# carpeta} other {# carpetas}}, {files, number} archivos). Por encima, la sincronización se detiene.', - fileSearchStorage: 'File Search: {maxSize} de almacenamiento (usas {size}). Por encima, la sincronización se detiene.', - codeTools: - 'Herramientas de código guardadas: {max} incluidas (tienes {current}). Las existentes siguen funcionando; no puedes añadir más.', - inline: 'El código inline (code_execute) no está incluido.', + connectors: '{paused} de {total} conectores en pausa (los menos usados). Eliges cuáles al iniciar sesión.', + mcpServers: '{paused} de {total} servidores MCP en pausa (los menos usados).', + mcpServersChoose: '{paused} de {total} servidores MCP en pausa (los menos usados). Eliges cuáles al iniciar sesión.', + users: '{max, plural, one {# usuario incluido} other {# usuarios incluidos}}, tienes {current}. Nadie pierde el acceso.', + fileSearch: 'File Search: hasta {maxFolders, plural, one {# carpeta} other {# carpetas}} y {maxFiles, number} archivos.', + fileSearchStorage: 'File Search: hasta {maxSize} de almacenamiento.', + codeTools: '{max} code tools guardados (tienes {current}).', + inline: 'Sin código inline (code_execute).', }, }, zh: { - title: '选择 {plan} 后的变化', - fits: '你目前使用的一切都在 {plan} 范围内。', + title: '{plan} 方案的变化', + fits: '你目前的使用量完全符合 {plan}。', chooseTeam: '改选 Team', lines: { - connectors: '你的 {total} 个连接器中有 {paused} 个将被暂停(使用最少的)。登录后可自行选择。', - mcpServers: '你的 {total} 个 MCP 服务器中有 {paused} 个将被暂停(使用最少的)。', - mcpServersChoose: '你的 {total} 个 MCP 服务器中有 {paused} 个将被暂停(使用最少的)。登录后可自行选择。', - users: '{plan} 包含 {max} 个用户;此工作区有 {current} 个。没有人会失去访问权限,但无法再邀请更多人。', - fileSearch: - 'File Search:{maxFolders} 个文件夹和 {maxFiles, number} 个文件(你有 {folders} 个文件夹、{files, number} 个文件)。超出部分将暂停同步。', - fileSearchStorage: 'File Search:{maxSize} 存储空间(你已使用 {size})。超出部分将暂停同步。', - codeTools: '已保存的代码工具:包含 {max} 个(你有 {current} 个)。现有工具继续可用,但无法再添加。', - inline: '不包含内联代码(code_execute)。', + connectors: '{total} 个连接器中有 {paused} 个暂停(使用最少的)。登录后可自行选择。', + mcpServers: '{total} 个 MCP 服务器中有 {paused} 个暂停(使用最少的)。', + mcpServersChoose: '{total} 个 MCP 服务器中有 {paused} 个暂停(使用最少的)。登录后可自行选择。', + users: '包含 {max} 个用户,你有 {current} 个。没有人会失去访问权限。', + fileSearch: 'File Search:最多 {maxFolders} 个文件夹和 {maxFiles, number} 个文件。', + fileSearchStorage: 'File Search:最多 {maxSize} 存储空间。', + codeTools: '{max} 个已保存的代码工具(你有 {current} 个)。', + inline: '不含内联代码(code_execute)。', }, }, ja: { - title: '{plan} にすると変わること', - fits: '現在お使いのものはすべて {plan} に収まります。', - chooseTeam: '代わりに Team を選ぶ', + title: '{plan} で変わること', + fits: '現在の利用内容はすべて {plan} に収まります。', + chooseTeam: 'Team を選ぶ', lines: { - connectors: '{total} 個のコネクタのうち {paused} 個が一時停止されます(利用の少ないもの)。どれにするかはサインイン後に選べます。', - mcpServers: '{total} 個の MCP サーバーのうち {paused} 個が一時停止されます(利用の少ないもの)。', - mcpServersChoose: - '{total} 個の MCP サーバーのうち {paused} 個が一時停止されます(利用の少ないもの)。どれにするかはサインイン後に選べます。', - users: '{plan} のユーザー数は {max} 人で、このワークスペースには {current} 人います。誰もアクセスを失いませんが、追加の招待はできません。', - fileSearch: - 'File Search:フォルダ {maxFolders} 個、ファイル {maxFiles, number} 件まで(現在フォルダ {folders} 個、ファイル {files, number} 件)。超えた分は同期が止まります。', - fileSearchStorage: 'File Search:ストレージ {maxSize}(現在 {size})。超えた分は同期が止まります。', - codeTools: '保存済みコードツール:{max} 個まで(現在 {current} 個)。既存のものは使えますが、追加はできません。', + connectors: '{total} 件中 {paused} 件のコネクタが一時停止(利用の少ないもの)。どれにするかはサインイン後に選べます。', + mcpServers: '{total} 件中 {paused} 件の MCP サーバーが一時停止(利用の少ないもの)。', + mcpServersChoose: '{total} 件中 {paused} 件の MCP サーバーが一時停止(利用の少ないもの)。どれにするかはサインイン後に選べます。', + users: 'ユーザー {max} 人まで(現在 {current} 人)。アクセスを失う人はいません。', + fileSearch: 'File Search:最大 {maxFolders} フォルダ、{maxFiles, number} ファイル。', + fileSearchStorage: 'File Search:最大 {maxSize} のストレージ。', + codeTools: '保存済みコードツール {max} 件(現在 {current} 件)。', inline: 'インラインコード(code_execute)は含まれません。', }, }, ru: { - title: 'Что изменится на тарифе {plan}', - fits: 'Всё, чем вы пользуетесь сейчас, укладывается в {plan}.', + title: 'Что изменится на {plan}', + fits: 'Всё, чем вы пользуетесь, помещается в {plan}.', chooseTeam: 'Выбрать Team', lines: { - connectors: - 'Будут приостановлены {paused} из {total} коннекторов (наименее используемые). Какие именно, вы выберете после входа.', - mcpServers: 'Будут приостановлены {paused} из {total} MCP-серверов (наименее используемые).', - mcpServersChoose: - 'Будут приостановлены {paused} из {total} MCP-серверов (наименее используемые). Какие именно, вы выберете после входа.', - users: - 'Пользователей в тарифе {plan}: {max}; в этом workspace: {current}. Никто не теряет доступ, но пригласить новых нельзя.', - fileSearch: - 'File Search: папок до {maxFolders}, файлов до {maxFiles, number} (у вас папок: {folders}, файлов: {files, number}). Сверх лимита синхронизация останавливается.', - fileSearchStorage: 'File Search: {maxSize} хранилища (у вас {size}). Сверх лимита синхронизация останавливается.', - codeTools: - 'Сохранённые инструменты кода: включено {max} (у вас {current}). Существующие продолжают работать; новые добавить нельзя.', - inline: 'Встроенный код (code_execute) не входит в тариф.', + connectors: '{paused} из {total} коннекторов на паузе (наименее используемые). Какие, выберете после входа.', + mcpServers: '{paused} из {total} MCP-серверов на паузе (наименее используемые).', + mcpServersChoose: '{paused} из {total} MCP-серверов на паузе (наименее используемые). Какие, выберете после входа.', + users: 'Пользователей: {max} (у вас {current}). Никто не теряет доступ.', + fileSearch: 'File Search: до {maxFolders} папок и {maxFiles, number} файлов.', + fileSearchStorage: 'File Search: до {maxSize} хранилища.', + codeTools: 'Сохранённых code tools: {max} (у вас {current}).', + inline: 'Без inline-кода (code_execute).', }, }, }; diff --git a/packages/frontend/src/app/reactivate/[token]/page.tsx b/packages/frontend/src/app/reactivate/[token]/page.tsx index 75c011dc5..3219de016 100644 --- a/packages/frontend/src/app/reactivate/[token]/page.tsx +++ b/packages/frontend/src/app/reactivate/[token]/page.tsx @@ -282,22 +282,6 @@ function ReactivateContent() { offer={view.offer ?? null} onEnterprise={() => track('enterprise_clicked')} /> - {/* 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 - } - /> -