diff --git a/packages/cli/src/controllers/__tests__/owner.controller.test.ts b/packages/cli/src/controllers/__tests__/owner.controller.test.ts index 1edfbd8af20..1b4f5b9fafd 100644 --- a/packages/cli/src/controllers/__tests__/owner.controller.test.ts +++ b/packages/cli/src/controllers/__tests__/owner.controller.test.ts @@ -49,11 +49,11 @@ describe('OwnerController', () => { }); it('should call dismissBanner with the correct banner name', async () => { - const payload = mock({ banner: 'TRIAL' }); + const payload = mock({ banner: 'TRIAL_OVER' }); await controller.dismissBanner(mock(), mock(), payload); - expect(bannerService.dismissBanner).toHaveBeenCalledWith('TRIAL'); + expect(bannerService.dismissBanner).toHaveBeenCalledWith('TRIAL_OVER'); }); }); }); diff --git a/packages/cli/src/services/__tests__/banner.service.test.ts b/packages/cli/src/services/__tests__/banner.service.test.ts index f2bc5582972..14bacd5dacd 100644 --- a/packages/cli/src/services/__tests__/banner.service.test.ts +++ b/packages/cli/src/services/__tests__/banner.service.test.ts @@ -1,4 +1,3 @@ -import type { BannerName } from '@n8n/api-types'; import type { SettingsRepository } from '@n8n/db'; import { mock } from 'jest-mock-extended'; import type { ErrorReporter } from 'n8n-core'; @@ -17,15 +16,14 @@ describe('BannerService', () => { describe('dismissBanner', () => { const key = 'ui.banners.dismissed'; - const bannerName: BannerName = 'TRIAL'; it('should save the banner name to settings if no banners are dismissed yet', async () => { settingsRepo.findOneBy.mockResolvedValue(null); - await bannerService.dismissBanner(bannerName); + await bannerService.dismissBanner('TRIAL'); expect(settingsRepo.save).toHaveBeenCalledWith( - { key, value: JSON.stringify([bannerName]), loadOnStartup: true }, + { key, value: JSON.stringify(['TRIAL']), loadOnStartup: true }, { transaction: false }, ); }); @@ -38,11 +36,27 @@ describe('BannerService', () => { loadOnStartup: false, }); - await bannerService.dismissBanner(bannerName); + await bannerService.dismissBanner('TRIAL'); expect(settingsRepo.update).toHaveBeenCalledWith( { key }, - { value: JSON.stringify([bannerName, 'TRIAL_OVER']), loadOnStartup: true }, + { value: JSON.stringify(['TRIAL', 'TRIAL_OVER']), loadOnStartup: true }, + ); + }); + + it('should not create duplicate entries if already dismissed', async () => { + const dismissedBanners = ['TRIAL', 'TRIAL_OVER']; + settingsRepo.findOneBy.mockResolvedValue({ + key, + value: JSON.stringify(dismissedBanners), + loadOnStartup: false, + }); + + await bannerService.dismissBanner('TRIAL'); + + expect(settingsRepo.update).toHaveBeenCalledWith( + { key }, + { value: JSON.stringify(['TRIAL', 'TRIAL_OVER']), loadOnStartup: true }, ); }); @@ -51,7 +65,7 @@ describe('BannerService', () => { settingsRepo.findOneBy.mockResolvedValue(null); settingsRepo.save.mockRejectedValue(error); - await bannerService.dismissBanner(bannerName); + await bannerService.dismissBanner('TRIAL'); expect(errorReporter.error).toHaveBeenCalledWith(error); }); diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 175a81bc48f..07e2932c510 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -104,6 +104,8 @@ "generic.unsavedWork.confirmMessage.message": "If you don't save, you will lose your changes.", "generic.unsavedWork.confirmMessage.confirmButtonText": "Save", "generic.unsavedWork.confirmMessage.cancelButtonText": "Leave without saving", + "generic.trial.message": "1 day left | {count} days left", + "generic.trial.tooltip": "1 day left in your trial. Upgrade to keep using n8n. | {count} days left in your trial. Upgrade to keep using n8n.", "generic.upgrade": "Upgrade", "generic.upgradeNow": "Upgrade now", "generic.update": "Update", diff --git a/packages/frontend/editor-ui/src/Interface.ts b/packages/frontend/editor-ui/src/Interface.ts index 1970d850865..5f9549255fc 100644 --- a/packages/frontend/editor-ui/src/Interface.ts +++ b/packages/frontend/editor-ui/src/Interface.ts @@ -847,11 +847,11 @@ export type CloudUpdateLinkSourceType = | 'evaluations' | 'ai-builder-sidebar' | 'ai-builder-canvas' - | 'custom-roles'; + | 'custom-roles' + | 'main-sidebar'; export type UTMCampaign = | 'upgrade-custom-data-filter' - | 'upgrade-canvas-nav' | 'upgrade-concurrency' | 'upgrade-workflow-sharing' | 'upgrade-credentials-sharing' @@ -874,7 +874,9 @@ export type UTMCampaign = | 'upgrade-insights' | 'upgrade-evaluations' | 'upgrade-builder' - | 'upgrade-custom-roles'; + | 'upgrade-custom-roles' + | 'upgrade-canvas-nav' + | 'upgrade-main-sidebar'; export type AddedNode = { type: string; diff --git a/packages/frontend/editor-ui/src/app/components/MainSidebar.vue b/packages/frontend/editor-ui/src/app/components/MainSidebar.vue index efff1fc097f..99738301779 100644 --- a/packages/frontend/editor-ui/src/app/components/MainSidebar.vue +++ b/packages/frontend/editor-ui/src/app/components/MainSidebar.vue @@ -57,6 +57,7 @@ import { useKeybindings } from '@/app/composables/useKeybindings'; import { useCalloutHelpers } from '@/app/composables/useCalloutHelpers'; import ProjectNavigation from '@/features/collaboration/projects/components/ProjectNavigation.vue'; import MainSidebarSourceControl from './MainSidebarSourceControl.vue'; +import MainSidebarTrialUpgrade from './MainSidebarTrialUpgrade.vue'; import MainSidebarUserArea from '@/app/components/MainSidebarUserArea.vue'; const becomeTemplateCreatorStore = useBecomeTemplateCreatorStore(); @@ -580,6 +581,7 @@ onClickOutside(createBtn as Ref, () => { + +import { N8nButton, N8nTooltip } from '@n8n/design-system'; +import { computed } from 'vue'; +import { useCloudPlanStore } from '@/app/stores/cloudPlan.store'; +import { useUIStore } from '@/app/stores/ui.store'; +import { i18n as locale } from '@n8n/i18n'; +import { usePageRedirectionHelper } from '@/app/composables/usePageRedirectionHelper'; + +const cloudPlanStore = useCloudPlanStore(); +const pageRedirectionHelper = usePageRedirectionHelper(); +const uiStore = useUIStore(); + +const isCollapsed = computed(() => uiStore.sidebarMenuCollapsed); + +const isVisible = computed(() => { + return cloudPlanStore.userIsTrialing && cloudPlanStore.isTrialUpgradeOnSidebar; +}); + +const trialDaysLeft = computed(() => -1 * cloudPlanStore.trialDaysLeft); + +const trialMessage = computed(() => { + return locale.baseText('generic.trial.message', { + adjustToNumber: trialDaysLeft.value, + interpolate: { count: String(trialDaysLeft.value) }, + }); +}); + +const tooltipContent = computed(() => { + return locale.baseText('generic.trial.tooltip', { + adjustToNumber: trialDaysLeft.value, + interpolate: { count: String(trialDaysLeft.value) }, + }); +}); + +const onUpgradeClick = () => { + void pageRedirectionHelper.goToUpgrade('main-sidebar', 'upgrade-main-sidebar', 'redirect'); +}; + + + + + diff --git a/packages/frontend/editor-ui/src/app/constants/experiments.ts b/packages/frontend/editor-ui/src/app/constants/experiments.ts index f0100f412b1..372824e31e5 100644 --- a/packages/frontend/editor-ui/src/app/constants/experiments.ts +++ b/packages/frontend/editor-ui/src/app/constants/experiments.ts @@ -55,6 +55,12 @@ export const TEMPLATE_RECO_V2 = { variant: 'variant', }; +export const UPGRADE_PLAN_CTA_EXPERIMENT = { + name: '054_upgrade_plan_cta', + control: 'control', + variant: 'variant', +}; + export const TEMPLATES_DATA_QUALITY_EXPERIMENT = { name: '046_templates_data_quality', control: 'control', @@ -114,6 +120,7 @@ export const EXPERIMENTS_TO_TRACK = [ TEMPLATE_RECO_V2.name, TEMPLATES_DATA_QUALITY_EXPERIMENT.name, READY_TO_RUN_V2_PART2_EXPERIMENT.name, + UPGRADE_PLAN_CTA_EXPERIMENT.name, AI_BUILDER_TEMPLATE_EXAMPLES_EXPERIMENT.name, AI_BUILDER_MULTI_AGENT_EXPERIMENT.name, TIME_SAVED_NODE_EXPERIMENT.name, diff --git a/packages/frontend/editor-ui/src/app/init.test.ts b/packages/frontend/editor-ui/src/app/init.test.ts index 8cf9b8876fd..5dcfc164c95 100644 --- a/packages/frontend/editor-ui/src/app/init.test.ts +++ b/packages/frontend/editor-ui/src/app/init.test.ts @@ -20,6 +20,7 @@ import merge from 'lodash/merge'; import { setActivePinia } from 'pinia'; import { mock } from 'vitest-mock-extended'; import { telemetry } from '@/app/plugins/telemetry'; +import { usePostHog } from './stores/posthog.store'; const showMessage = vi.fn(); const showToast = vi.fn(); @@ -47,6 +48,7 @@ describe('Init', () => { let ssoStore: ReturnType>; let rootStore: ReturnType>; let bannersStore: ReturnType>; + let posthogStore: ReturnType>; beforeEach(() => { setActivePinia( @@ -67,6 +69,7 @@ describe('Init', () => { ssoStore = mockedStore(useSSOStore); rootStore = mockedStore(useRootStore); bannersStore = mockedStore(useBannersStore); + posthogStore = mockedStore(usePostHog); }); describe('initializeCore()', () => { @@ -291,7 +294,7 @@ describe('Init', () => { expect(bannersStore.pushBannerToStack).toHaveBeenCalledWith('TRIAL_OVER'); }); - it('should push TRIAL banner if trial is active', async () => { + it('should push TRIAL banner if trial is active and does not have feature flag set', async () => { settingsStore.settings.deployment.type = 'cloud'; usersStore.usersById = { '123': { id: '123', email: '' } as IUser }; usersStore.currentUserId = '123'; @@ -307,6 +310,42 @@ describe('Init', () => { expect(bannersStore.pushBannerToStack).toHaveBeenCalledWith('TRIAL'); }); + it('should push TRIAL banner if trial is active and has feature flag control', async () => { + settingsStore.settings.deployment.type = 'cloud'; + usersStore.usersById = { '123': { id: '123', email: '' } as IUser }; + usersStore.currentUserId = '123'; + + cloudPlanStore.userIsTrialing = true; + cloudPlanStore.trialExpired = false; + + const posthogStoreSpy = vi.spyOn(posthogStore, 'getVariant').mockReturnValueOnce('control'); + const cloudStoreSpy = vi.spyOn(cloudPlanStore, 'initialize').mockResolvedValueOnce(); + + await initializeAuthenticatedFeatures(false); + + expect(posthogStoreSpy).toHaveBeenCalled(); + expect(cloudStoreSpy).toHaveBeenCalled(); + expect(bannersStore.pushBannerToStack).toHaveBeenCalledWith('TRIAL'); + }); + + it('should not push TRIAL banner if trial is active and has feature flag variant', async () => { + settingsStore.settings.deployment.type = 'cloud'; + usersStore.usersById = { '123': { id: '123', email: '' } as IUser }; + usersStore.currentUserId = '123'; + + cloudPlanStore.userIsTrialing = true; + cloudPlanStore.trialExpired = false; + + const posthogStoreSpy = vi.spyOn(posthogStore, 'getVariant').mockReturnValueOnce('variant'); + const cloudStoreSpy = vi.spyOn(cloudPlanStore, 'initialize').mockResolvedValueOnce(); + + await initializeAuthenticatedFeatures(false); + + expect(posthogStoreSpy).toHaveBeenCalled(); + expect(cloudStoreSpy).toHaveBeenCalled(); + expect(bannersStore.pushBannerToStack).not.toHaveBeenCalledWith('TRIAL'); + }); + it('should push EMAIL_CONFIRMATION banner if user cloud info is not confirmed', async () => { settingsStore.settings.deployment.type = 'cloud'; usersStore.usersById = { '123': { id: '123', email: '' } as IUser }; diff --git a/packages/frontend/editor-ui/src/app/init.ts b/packages/frontend/editor-ui/src/app/init.ts index 139e86693fe..52f80d68020 100644 --- a/packages/frontend/editor-ui/src/app/init.ts +++ b/packages/frontend/editor-ui/src/app/init.ts @@ -163,7 +163,9 @@ export async function initializeAuthenticatedFeatures( if (cloudPlanStore.trialExpired) { bannersStore.pushBannerToStack('TRIAL_OVER'); } else { - bannersStore.pushBannerToStack('TRIAL'); + if (!cloudPlanStore.isTrialUpgradeOnSidebar) { + bannersStore.pushBannerToStack('TRIAL'); + } } } else if (cloudPlanStore.currentUserCloudInfo?.confirmed === false) { bannersStore.pushBannerToStack('EMAIL_CONFIRMATION'); diff --git a/packages/frontend/editor-ui/src/app/stores/cloudPlan.store.ts b/packages/frontend/editor-ui/src/app/stores/cloudPlan.store.ts index 9f94a10e68b..402162f1528 100644 --- a/packages/frontend/editor-ui/src/app/stores/cloudPlan.store.ts +++ b/packages/frontend/editor-ui/src/app/stores/cloudPlan.store.ts @@ -10,10 +10,11 @@ import { getCurrentUsage, } from '@n8n/rest-api-client/api/cloudPlans'; import { DateTime } from 'luxon'; -import { CLOUD_TRIAL_CHECK_INTERVAL } from '@/app/constants'; +import { CLOUD_TRIAL_CHECK_INTERVAL, UPGRADE_PLAN_CTA_EXPERIMENT } from '@/app/constants'; import { STORES } from '@n8n/stores'; import { hasPermission } from '@/app/utils/rbac/permissions'; import * as cloudApi from '@n8n/rest-api-client/api/cloudPlans'; +import { usePostHog } from './posthog.store'; const DEFAULT_STATE: CloudPlanState = { initialized: false, @@ -27,6 +28,7 @@ const DYNAMIC_TRIAL_BANNER_DISMISSED_KEY = 'n8n-dynamic-trial-banner-dismissed'; export const useCloudPlanStore = defineStore(STORES.CLOUD_PLAN, () => { const rootStore = useRootStore(); const settingsStore = useSettingsStore(); + const posthogStore = usePostHog(); const state = reactive(DEFAULT_STATE); const currentUserCloudInfo = ref(null); @@ -114,6 +116,13 @@ export const useCloudPlanStore = defineStore(STORES.CLOUD_PLAN, () => { return await getAdminPanelLoginCode(rootStore.restApiContext); }; + const isTrialUpgradeOnSidebar = computed(() => { + return ( + posthogStore.getVariant(UPGRADE_PLAN_CTA_EXPERIMENT.name) === + UPGRADE_PLAN_CTA_EXPERIMENT.variant + ); + }); + const getOwnerCurrentPlan = async () => { if (!hasCloudPlan.value) throw new Error('User does not have a cloud plan'); state.loadingPlan = true; @@ -242,5 +251,6 @@ export const useCloudPlanStore = defineStore(STORES.CLOUD_PLAN, () => { dynamicTrialBannerText, shouldShowDynamicTrialBanner, dismissDynamicTrialBanner, + isTrialUpgradeOnSidebar, }; }); diff --git a/packages/frontend/editor-ui/src/features/shared/banners/components/banners/TrialBanner.vue b/packages/frontend/editor-ui/src/features/shared/banners/components/banners/TrialBanner.vue index 146c43ac078..45e73324222 100644 --- a/packages/frontend/editor-ui/src/features/shared/banners/components/banners/TrialBanner.vue +++ b/packages/frontend/editor-ui/src/features/shared/banners/components/banners/TrialBanner.vue @@ -102,7 +102,7 @@ function onUpdatePlanClick() { :bold="true" >{{ trialBannerText }} - {{ + {{ locale.baseText('generic.upgradeNow') }} diff --git a/packages/frontend/editor-ui/src/features/shared/banners/components/banners/TrialOverBanner.vue b/packages/frontend/editor-ui/src/features/shared/banners/components/banners/TrialOverBanner.vue index f2c5748007c..12635005cea 100644 --- a/packages/frontend/editor-ui/src/features/shared/banners/components/banners/TrialOverBanner.vue +++ b/packages/frontend/editor-ui/src/features/shared/banners/components/banners/TrialOverBanner.vue @@ -4,8 +4,11 @@ import { usePageRedirectionHelper } from '@/app/composables/usePageRedirectionHe import { i18n as locale } from '@n8n/i18n'; import { N8nButton } from '@n8n/design-system'; -function onUpdatePlanClick() { - void usePageRedirectionHelper().goToUpgrade('canvas-nav', 'upgrade-canvas-nav', 'redirect'); + +const pageRedirectionHelper = usePageRedirectionHelper(); + +function onUpgradePlanClick() { + void pageRedirectionHelper.goToUpgrade('canvas-nav', 'upgrade-canvas-nav', 'redirect'); } @@ -15,7 +18,7 @@ function onUpdatePlanClick() { {{ locale.baseText('banners.trialOver.message') }} diff --git a/packages/testing/playwright/fixtures/plan-data-non-trial.json b/packages/testing/playwright/fixtures/plan-data-non-trial.json new file mode 100644 index 00000000000..2801406a192 --- /dev/null +++ b/packages/testing/playwright/fixtures/plan-data-non-trial.json @@ -0,0 +1,24 @@ +{ + "id": 200, + "planId": 1, + "pruneExecutionsInterval": 168, + "monthlyExecutionsLimit": 1000, + "activeWorkflowsLimit": 20, + "credentialsLimit": 100, + "supportTier": "community", + "displayName": "Starter", + "enabledFeatures": ["userManagement", "advancedExecutionFilters", "sharing"], + "licenseFeatures": { + "feat:sharing": true, + "feat:advancedExecutionFilters": true, + "quota:users": -1, + "quota:maxVariables": -1, + "feat:variables": true + }, + "metadata": { + "version": "v1", + "group": "starter", + "slug": "starter" + }, + "expirationDate": "2023-08-30T15:47:27.611Z" +} diff --git a/packages/testing/playwright/pages/SidebarPage.ts b/packages/testing/playwright/pages/SidebarPage.ts index 3b520321df0..e1a8eac3adc 100644 --- a/packages/testing/playwright/pages/SidebarPage.ts +++ b/packages/testing/playwright/pages/SidebarPage.ts @@ -98,6 +98,10 @@ export class SidebarPage { return this.page.getByTestId('banners-TRIAL'); } + getMainSidebarTrialUpgrade(): Locator { + return this.page.getByTestId('main-sidebar-trial-upgrade'); + } + getTemplatesLink(): Locator { return this.page.getByTestId('templates').locator('a'); } diff --git a/packages/testing/playwright/tests/ui/cloud/cloud.spec.ts b/packages/testing/playwright/tests/ui/cloud/cloud.spec.ts index 04ced8d2f6a..d595953140f 100644 --- a/packages/testing/playwright/tests/ui/cloud/cloud.spec.ts +++ b/packages/testing/playwright/tests/ui/cloud/cloud.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '../../../fixtures/base'; +import nonTrialPlanData from '../../../fixtures/plan-data-non-trial.json'; import basePlanData from '../../../fixtures/plan-data-trial.json'; import type { n8nPage } from '../../../pages/n8nPage'; import type { TestRequirements } from '../../../Types'; @@ -6,7 +7,7 @@ import type { TestRequirements } from '../../../Types'; const fiveDaysFromNow = new Date(Date.now() + 5 * 24 * 60 * 60 * 1000); const planData = { ...basePlanData, expirationDate: fiveDaysFromNow.toJSON() }; -const cloudTrialRequirements = { +const cloudTrialRequirements: TestRequirements = { config: { settings: { deployment: { type: 'cloud' }, @@ -32,6 +33,32 @@ const cloudTrialRequirements = { }, }; +const cloudNonTrialRequirements: TestRequirements = { + config: { + settings: { + deployment: { type: 'cloud' }, + n8nMetadata: { userId: '1' }, + aiCredits: { + enabled: true, + credits: 100, + }, + banners: { + dismissed: ['V1'], // Prevent V1 banner interference + }, + }, + }, + intercepts: { + 'cloud-plan': { + url: '**/rest/admin/cloud-plan', + response: { ...nonTrialPlanData, expirationDate: fiveDaysFromNow.toJSON() }, + }, + 'cloud-user': { + url: '**/rest/cloud/proxy/user/me', + response: {}, + }, + }, +}; + const setupCloudTest = async ( n8n: n8nPage, setupRequirements: (requirements: TestRequirements) => Promise, @@ -48,14 +75,72 @@ const createProjectAndNavigate = async (n8n: n8nPage) => { }; test.describe('Cloud @db:reset @auth:owner', () => { - test.describe('Trial Banner', () => { - test('should render trial banner for opt-in cloud user', async ({ n8n, setupRequirements }) => { + test.describe('Trial Upgrade', () => { + test('should render trial banner for opt-in cloud user and has feature flag control', async ({ + n8n, + setupRequirements, + }) => { + await setupCloudTest(n8n, setupRequirements, cloudTrialRequirements); + await n8n.start.fromBlankCanvas(); + await n8n.sideBar.expand(); + + await n8n.page.evaluate(() => { + ( + window as unknown as { featureFlags: { override: (name: string, value: string) => void } } + ).featureFlags?.override('054_upgrade_plan_cta', 'control'); + }); + + await expect(n8n.sideBar.getTrialBanner()).toBeVisible(); + }); + + test('should render trial banner for opt-in cloud user and has no feature flag set', async ({ + n8n, + setupRequirements, + }) => { await setupCloudTest(n8n, setupRequirements, cloudTrialRequirements); await n8n.start.fromBlankCanvas(); await n8n.sideBar.expand(); await expect(n8n.sideBar.getTrialBanner()).toBeVisible(); }); + + test('should show trial upgrade in the main sidebar if user is trialing and has feature flag enabled', async ({ + n8n, + setupRequirements, + }) => { + await setupCloudTest(n8n, setupRequirements, cloudTrialRequirements); + await n8n.start.fromBlankCanvas(); + await n8n.sideBar.expand(); + + await n8n.page.evaluate(() => { + ( + window as unknown as { featureFlags: { override: (name: string, value: string) => void } } + ).featureFlags?.override('054_upgrade_plan_cta', 'variant'); + }); + + await n8n.page.reload(); + + await expect(n8n.sideBar.getMainSidebarTrialUpgrade()).toBeVisible(); + }); + + test('should not show trial upgrade in the main sidebar if user is not trialing and feature flag is enabled', async ({ + n8n, + setupRequirements, + }) => { + await setupCloudTest(n8n, setupRequirements, cloudNonTrialRequirements); + await n8n.start.fromBlankCanvas(); + await n8n.sideBar.expand(); + + await n8n.page.evaluate(() => { + ( + window as unknown as { featureFlags: { override: (name: string, value: string) => void } } + ).featureFlags?.override('054_upgrade_plan_cta', 'variant'); + }); + + await n8n.page.reload(); + + await expect(n8n.sideBar.getMainSidebarTrialUpgrade()).not.toBeVisible(); + }); }); test.describe('Admin Home', () => {