diff --git a/packages/cli/config/schema.ts b/packages/cli/config/schema.ts index 50111f874a..6eb277754c 100644 --- a/packages/cli/config/schema.ts +++ b/packages/cli/config/schema.ts @@ -909,4 +909,13 @@ export const schema = { default: 'en', env: 'N8N_DEFAULT_LOCALE', }, + + onboardingCallPrompt: { + enabled: { + doc: 'Whether onboarding call propmpt feature is available', + format: Boolean, + default: true, + env: 'N8N_ONBOARDING_CALL_PROMPTS_ENABLED', + }, + }, }; diff --git a/packages/cli/src/Interfaces.ts b/packages/cli/src/Interfaces.ts index e59cd84238..0b81e71112 100644 --- a/packages/cli/src/Interfaces.ts +++ b/packages/cli/src/Interfaces.ts @@ -512,6 +512,7 @@ export interface IN8nUISettings { enabled: boolean; host: string; }; + onboardingCallPromptEnabled: boolean; missingPackages?: boolean; executionMode: 'regular' | 'queue'; communityNodesEnabled: boolean; diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index b4aeac677d..f0830e9280 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -335,6 +335,7 @@ class App { enabled: config.getEnv('templates.enabled'), host: config.getEnv('templates.host'), }, + onboardingCallPromptEnabled: config.getEnv('onboardingCallPrompt.enabled'), executionMode: config.getEnv('executions.mode'), communityNodesEnabled: config.getEnv('nodes.communityPackages.enabled'), }; diff --git a/packages/cli/src/UserManagement/Interfaces.ts b/packages/cli/src/UserManagement/Interfaces.ts index 4f64479b92..85e086cd64 100644 --- a/packages/cli/src/UserManagement/Interfaces.ts +++ b/packages/cli/src/UserManagement/Interfaces.ts @@ -1,8 +1,8 @@ /* eslint-disable import/no-cycle */ import { Application } from 'express'; import { JwtFromRequestFunction } from 'passport-jwt'; -import type { IExternalHooksClass, IPersonalizationSurveyAnswers } from '../Interfaces'; import { ActiveWorkflowRunner } from '..'; +import type { IExternalHooksClass, IPersonalizationSurveyAnswers } from '../Interfaces'; export interface JwtToken { token: string; @@ -28,6 +28,7 @@ export interface PublicUser { personalizationAnswers?: IPersonalizationSurveyAnswers | null; password?: string; passwordResetToken?: string; + createdAt: Date; isPending: boolean; } diff --git a/packages/cli/src/UserManagement/UserManagementHelper.ts b/packages/cli/src/UserManagement/UserManagementHelper.ts index 15ae12e013..978f8117d7 100644 --- a/packages/cli/src/UserManagement/UserManagementHelper.ts +++ b/packages/cli/src/UserManagement/UserManagementHelper.ts @@ -121,7 +121,6 @@ export function sanitizeUser(user: User, withoutKeys?: string[]): PublicUser { password, resetPasswordToken, resetPasswordTokenExpiration, - createdAt, updatedAt, apiKey, ...sanitizedUser diff --git a/packages/design-system/src/components/N8nCheckbox/Checkbox.vue b/packages/design-system/src/components/N8nCheckbox/Checkbox.vue index 88c4c31780..3ea6b33077 100644 --- a/packages/design-system/src/components/N8nCheckbox/Checkbox.vue +++ b/packages/design-system/src/components/N8nCheckbox/Checkbox.vue @@ -1,6 +1,7 @@ diff --git a/packages/design-system/src/components/N8nFormInputs/FormInputs.stories.js b/packages/design-system/src/components/N8nFormInputs/FormInputs.stories.js index 3f8b588511..95dc6513b3 100644 --- a/packages/design-system/src/components/N8nFormInputs/FormInputs.stories.js +++ b/packages/design-system/src/components/N8nFormInputs/FormInputs.stories.js @@ -72,9 +72,9 @@ FormInputs.args = { name: 'agree', properties: { type: 'checkbox', - label: 'Signup for newsletter Signup for newsletter Signup for newsletter vSignup for newsletter Signup for newsletter Signup for newsletter Signup for newsletter Signup for newsletter Signup for newsletter Signup for newsletter v vSignup for newsletter Signup for newsletter Signup for newsletter Signup for newsletter', + label: 'Signup for newsletter and somebody from our marketing team will get in touch with you as soon as possible. You will not spam you, just want to send you some love every now and then ❤️', labelSize: 'small', - tooltipText: 'Check this if you agree to be contacted by our marketing team Check this if you agree to be contacted by our marketing team Check this if you agree to be contacted by our marketing team Check this if you agree to be contacted by our marketing team' + tooltipText: 'Check this if you agree to be contacted by our marketing team' } } ], diff --git a/packages/design-system/src/types/form.ts b/packages/design-system/src/types/form.ts index 3df8d1c0cd..a86bf5ec24 100644 --- a/packages/design-system/src/types/form.ts +++ b/packages/design-system/src/types/form.ts @@ -15,7 +15,7 @@ export type IFormInput = { initialValue?: string | number | boolean | null; properties: { label?: string; - type?: 'text' | 'email' | 'password' | 'select' | 'multi-select' | 'info'; + type?: 'text' | 'email' | 'password' | 'select' | 'multi-select' | 'info'| 'checkbox'; maxlength?: number; required?: boolean; showRequiredAsterisk?: boolean; diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index 917ee23457..a2f089681d 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -24,6 +24,7 @@ import { WorkflowExecuteMode, PublicInstalledPackage, } from 'n8n-workflow'; +import { FAKE_DOOR_FEATURES } from './constants'; export * from 'n8n-design-system/src/types'; @@ -542,6 +543,7 @@ export interface IUserResponse { globalRole?: { name: IRole; id: string; + createdAt: Date; }; personalizationAnswers?: IPersonalizationSurveyAnswersV1 | IPersonalizationSurveyAnswersV2 | null; isPending: boolean; @@ -552,6 +554,7 @@ export interface IUser extends IUserResponse { isPendingUser: boolean; isOwner: boolean; fullName?: string; + createdAt?: Date; } export interface IVersionNotificationSettings { @@ -701,6 +704,7 @@ export interface IN8nUISettings { latestVersion: number; path: string; }; + onboardingCallPromptEnabled: boolean; } export interface IWorkflowSettings extends IWorkflowSettingsWorkflow { @@ -917,6 +921,7 @@ export interface IUiState { mappingTelemetry: {[key: string]: string | number | boolean}; }; mainPanelPosition: number; + fakeDoorFeatures: IFakeDoor[]; draggable: { isDragging: boolean; type: string; @@ -928,6 +933,19 @@ export interface IUiState { export type ILogLevel = 'info' | 'debug' | 'warn' | 'error' | 'verbose'; +export type IFakeDoor = { + id: FAKE_DOOR_FEATURES, + featureName: string, + icon?: string, + infoText?: string, + actionBoxTitle: string, + actionBoxDescription: string, + linkURL: string, + uiLocations: IFakeDoorLocation[], +}; + +export type IFakeDoorLocation = 'settings' | 'credentialsModal'; + export interface ISettingsState { settings: IN8nUISettings; promptsData: IN8nPrompts; @@ -938,6 +956,7 @@ export interface ISettingsState { latestVersion: number; path: string; }; + onboardingCallPromptEnabled: boolean; } export interface ITemplateState { @@ -1006,6 +1025,16 @@ export interface IInviteResponse { error?: string; } +export interface IOnboardingCallPromptResponse { + nextPrompt: IOnboardingCallPrompt; +} + +export interface IOnboardingCallPrompt { + title: string; + body: string; + index: number; +} + export interface ITab { value: string | number; label?: string; diff --git a/packages/editor-ui/src/api/users.ts b/packages/editor-ui/src/api/users.ts index af9f1bd8b4..a2b2b1bc6a 100644 --- a/packages/editor-ui/src/api/users.ts +++ b/packages/editor-ui/src/api/users.ts @@ -1,6 +1,6 @@ -import { IInviteResponse, IPersonalizationSurveyAnswersV2, IRestApiContext, IUserResponse } from '@/Interface'; +import { IInviteResponse, IOnboardingCallPromptResponse, IPersonalizationSurveyAnswersV2, IRestApiContext, IUserResponse } from '@/Interface'; import { IDataObject } from 'n8n-workflow'; -import { makeRestApiRequest } from './helpers'; +import { get, makeRestApiRequest } from './helpers'; export function loginCurrentUser(context: IRestApiContext): Promise { return makeRestApiRequest(context, 'GET', '/login'); diff --git a/packages/editor-ui/src/api/workflow-webhooks.ts b/packages/editor-ui/src/api/workflow-webhooks.ts new file mode 100644 index 0000000000..04176dcf5b --- /dev/null +++ b/packages/editor-ui/src/api/workflow-webhooks.ts @@ -0,0 +1,49 @@ +import { IOnboardingCallPromptResponse, IUser } from "@/Interface"; +import { get, post } from "./helpers"; + +const N8N_API_BASE_URL = 'https://api.n8n.io/api'; +const ONBOARDING_PROMPTS_ENDPOINT = '/prompts/onboarding'; +const CONTACT_EMAIL_SUBMISSION_ENDPOINT = '/accounts/onboarding'; + +export async function fetchNextOnboardingPrompt(instanceId: string, currentUer: IUser): Promise { + return await get( + N8N_API_BASE_URL, + ONBOARDING_PROMPTS_ENDPOINT, + { + instance_id: instanceId, + user_id: `${instanceId}#${currentUer.id}`, + is_owner: currentUer.isOwner, + survey_results: currentUer.personalizationAnswers, + }, + ); +} + +export async function applyForOnboardingCall(instanceId: string, currentUer: IUser, email: string): Promise { + try { + const response = await post( + N8N_API_BASE_URL, + ONBOARDING_PROMPTS_ENDPOINT, + { + instance_id: instanceId, + user_id: `${instanceId}#${currentUer.id}`, + email, + }, + ); + return response; + } catch (e) { + throw e; + } +} + +export async function submitEmailOnSignup(instanceId: string, currentUer: IUser, email: string, agree: boolean): Promise { + return await post( + N8N_API_BASE_URL, + CONTACT_EMAIL_SUBMISSION_ENDPOINT, + { + instance_id: instanceId, + user_id: `${instanceId}#${currentUer.id}`, + email, + agree, + }, + ); +} diff --git a/packages/editor-ui/src/components/CredentialEdit/CredentialEdit.vue b/packages/editor-ui/src/components/CredentialEdit/CredentialEdit.vue index 6930734ac7..945890bebc 100644 --- a/packages/editor-ui/src/components/CredentialEdit/CredentialEdit.vue +++ b/packages/editor-ui/src/components/CredentialEdit/CredentialEdit.vue @@ -57,6 +57,14 @@ {{ $locale.baseText('credentialEdit.credentialEdit.connection') }} + + {{ $locale.baseText(fakeDoor.featureName) }} + {{ $locale.baseText('credentialEdit.credentialEdit.details') }} @@ -89,6 +97,9 @@ @accessChange="onNodeAccessChange" /> + + + @@ -100,6 +111,7 @@ import Vue from 'vue'; import { ICredentialsDecryptedResponse, ICredentialsResponse, + IFakeDoor, } from '@/Interface'; import { @@ -108,6 +120,7 @@ import { ICredentialNodeAccess, ICredentialsDecrypted, ICredentialType, + INode, INodeCredentialTestResult, INodeParameters, INodeProperties, @@ -126,6 +139,7 @@ import CredentialInfo from './CredentialInfo.vue'; import SaveButton from '../SaveButton.vue'; import Modal from '../Modal.vue'; import InlineNameEdit from '../InlineNameEdit.vue'; +import FeatureComingSoon from '../FeatureComingSoon.vue'; interface NodeAccessMap { [nodeType: string]: ICredentialNodeAccess | null; @@ -140,6 +154,7 @@ export default mixins(showMessage, nodeHelpers).extend({ InlineNameEdit, Modal, SaveButton, + FeatureComingSoon, }, props: { modalName: { @@ -351,6 +366,9 @@ export default mixins(showMessage, nodeHelpers).extend({ } return true; }, + credentialsFakeDoorFeatures(): IFakeDoor[] { + return this.$store.getters['ui/getFakeDoorByLocation']('credentialsModal'); + }, }, methods: { async beforeClose() { @@ -474,6 +492,15 @@ export default mixins(showMessage, nodeHelpers).extend({ }, onTabSelect(tab: string) { this.activeTab = tab; + const tabName: string = tab.replaceAll('coming-soon/', ''); + const credType: string = this.credentialType ? this.credentialType.name : ''; + const activeNode: INode | null = this.$store.getters.activeNode; + + this.$telemetry.track('User viewed credential tab', { + credential_type: credType, + node_type: activeNode ? activeNode.type : null, + tab: tabName, + }); }, onNodeAccessChange({name, value}: {name: string, value: boolean}) { this.hasUnsavedChanges = true; diff --git a/packages/editor-ui/src/components/FeatureComingSoon.vue b/packages/editor-ui/src/components/FeatureComingSoon.vue new file mode 100644 index 0000000000..327643d8c3 --- /dev/null +++ b/packages/editor-ui/src/components/FeatureComingSoon.vue @@ -0,0 +1,71 @@ + + + + {{ $locale.baseText(featureInfo.featureName) }} + + + + + + + + + + + + + + + + + + diff --git a/packages/editor-ui/src/components/Modals.vue b/packages/editor-ui/src/components/Modals.vue index e8a3259fd9..733fd03986 100644 --- a/packages/editor-ui/src/components/Modals.vue +++ b/packages/editor-ui/src/components/Modals.vue @@ -89,6 +89,10 @@ + + + + @@ -102,6 +106,7 @@ /> + @@ -120,6 +125,7 @@ import { DUPLICATE_MODAL_KEY, EXECUTIONS_MODAL_KEY, INVITE_USER_MODAL_KEY, + ONBOARDING_CALL_SIGNUP_MODAL_KEY, PERSONALIZATION_MODAL_KEY, TAGS_MANAGER_MODAL_KEY, VALUE_SURVEY_MODAL_KEY, @@ -140,6 +146,7 @@ import InviteUsersModal from "./InviteUsersModal.vue"; import CredentialsSelectModal from "./CredentialsSelectModal.vue"; import DuplicateWorkflowDialog from "./DuplicateWorkflowDialog.vue"; import ModalRoot from "./ModalRoot.vue"; +import OnboardingCallSignupModal from './OnboardingCallSignupModal.vue'; import PersonalizationModal from "./PersonalizationModal.vue"; import TagsManager from "./TagsManager/TagsManager.vue"; import UpdatesPanel from "./UpdatesPanel.vue"; @@ -167,6 +174,7 @@ export default Vue.extend({ InviteUsersModal, ExecutionsList, ModalRoot, + OnboardingCallSignupModal, PersonalizationModal, TagsManager, UpdatesPanel, @@ -185,6 +193,7 @@ export default Vue.extend({ CHANGE_PASSWORD_MODAL_KEY, DELETE_USER_MODAL_KEY, DUPLICATE_MODAL_KEY, + ONBOARDING_CALL_SIGNUP_MODAL_KEY, PERSONALIZATION_MODAL_KEY, INVITE_USER_MODAL_KEY, TAGS_MANAGER_MODAL_KEY, diff --git a/packages/editor-ui/src/components/OnboardingCallSignupModal.vue b/packages/editor-ui/src/components/OnboardingCallSignupModal.vue new file mode 100644 index 0000000000..aa57f26dff --- /dev/null +++ b/packages/editor-ui/src/components/OnboardingCallSignupModal.vue @@ -0,0 +1,128 @@ + + + + + + {{ $locale.baseText('onboardingCallSignupModal.description') }} + + + + + + {{ $locale.baseText('onboardingCallSignupModal.infoText.emailError') }} + + + + + + + + + + + + + + + diff --git a/packages/editor-ui/src/components/PersonalizationModal.vue b/packages/editor-ui/src/components/PersonalizationModal.vue index 0959b741d2..eed74e84f0 100644 --- a/packages/editor-ui/src/components/PersonalizationModal.vue +++ b/packages/editor-ui/src/components/PersonalizationModal.vue @@ -108,6 +108,9 @@ import { OTHER_FOCUS, COMPANY_INDUSTRY_EXTENDED_KEY, OTHER_COMPANY_INDUSTRY_EXTENDED_KEY, + ONBOARDING_PROMPT_TIMEBOX, + FIRST_ONBOARDING_PROMPT_TIMEOUT, + ONBOARDING_CALL_SIGNUP_MODAL_KEY, } from '../constants'; import { workflowHelpers } from '@/components/mixins/workflowHelpers'; import { showMessage } from '@/components/mixins/showMessage'; @@ -115,6 +118,7 @@ import Modal from './Modal.vue'; import { IFormInput, IFormInputs, IPersonalizationSurveyAnswersV2 } from '@/Interface'; import Vue from 'vue'; import { mapGetters } from 'vuex'; +import { getAccountAge } from '@/modules/userHelpers'; export default mixins(showMessage, workflowHelpers).extend({ components: { Modal }, @@ -135,6 +139,12 @@ export default mixins(showMessage, workflowHelpers).extend({ ...mapGetters({ baseUrl: 'getBaseUrl', }), + ...mapGetters('users', [ + 'currentUser', + ]), + ...mapGetters('settings', [ + 'isOnboardingCallPromptFeatureEnabled', + ]), survey() { const survey: IFormInputs = [ { @@ -500,6 +510,7 @@ export default mixins(showMessage, workflowHelpers).extend({ this.closeDialog(); } + await this.fetchOnboardingPrompt(); this.submitted = true; } catch (e) { this.$showError(e, 'Error while submitting results'); @@ -507,6 +518,33 @@ export default mixins(showMessage, workflowHelpers).extend({ this.$data.isSaving = false; }, + async fetchOnboardingPrompt() { + if (this.isOnboardingCallPromptFeatureEnabled && getAccountAge(this.currentUser) <= ONBOARDING_PROMPT_TIMEBOX) { + const onboardingResponse = await this.$store.dispatch('ui/getNextOnboardingPrompt'); + const promptTimeout = onboardingResponse.toast_sequence_number === 1 ? FIRST_ONBOARDING_PROMPT_TIMEOUT : 1000; + + if (onboardingResponse.title && onboardingResponse.description) { + setTimeout(async () => { + this.$showToast({ + type: 'info', + title: onboardingResponse.title, + message: onboardingResponse.description, + duration: 0, + customClass: 'clickable', + closeOnClick: true, + onClick: () => { + this.$telemetry.track('user clicked onboarding toast', { + seq_num: onboardingResponse.toast_sequence_number, + title: onboardingResponse.title, + description: onboardingResponse.description, + }); + this.$store.commit('ui/openModal', ONBOARDING_CALL_SIGNUP_MODAL_KEY, {root: true}); + }, + }); + }, promptTimeout); + } + } + }, }, }); diff --git a/packages/editor-ui/src/components/SettingsSidebar.vue b/packages/editor-ui/src/components/SettingsSidebar.vue index 1339291004..c90984969d 100644 --- a/packages/editor-ui/src/components/SettingsSidebar.vue +++ b/packages/editor-ui/src/components/SettingsSidebar.vue @@ -25,6 +25,17 @@ {{ $locale.baseText('settings.n8napi') }} + + + + + {{ $locale.baseText(fakeDoor.featureName) }} + @@ -45,6 +56,7 @@ import mixins from 'vue-typed-mixins'; import { mapGetters } from 'vuex'; import { ABOUT_MODAL_KEY, VIEWS } from '@/constants'; import { userHelpers } from './mixins/userHelpers'; +import { IFakeDoor } from '@/Interface'; export default mixins( userHelpers, @@ -52,6 +64,9 @@ export default mixins( name: 'SettingsSidebar', computed: { ...mapGetters('settings', ['versionCli']), + settingsFakeDoorFeatures(): IFakeDoor[] { + return this.$store.getters['ui/getFakeDoorByLocation']('settings'); + }, }, methods: { canAccessPersonalSettings(): boolean { diff --git a/packages/editor-ui/src/constants.ts b/packages/editor-ui/src/constants.ts index a6d81a7ba0..61af239130 100644 --- a/packages/editor-ui/src/constants.ts +++ b/packages/editor-ui/src/constants.ts @@ -40,6 +40,7 @@ export const CONTACT_PROMPT_MODAL_KEY = 'contactPrompt'; export const VALUE_SURVEY_MODAL_KEY = 'valueSurvey'; export const EXECUTIONS_MODAL_KEY = 'executions'; export const WORKFLOW_ACTIVE_MODAL_KEY = 'activation'; +export const ONBOARDING_CALL_SIGNUP_MODAL_KEY = 'onboardingCallSignup'; export const COMMUNITY_PACKAGE_INSTALL_MODAL_KEY = 'communityPackageInstall'; export const COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY = 'communityPackageManageConfirm'; @@ -55,7 +56,6 @@ export const BREAKPOINT_MD = 992; export const BREAKPOINT_LG = 1200; export const BREAKPOINT_XL = 1920; - export const N8N_IO_BASE_URL = `https://api.n8n.io/api/`; export const DATA_PINNING_DOCS_URL = 'https://docs.n8n.io/data/data-pinning/'; export const NPM_COMMUNITY_NODE_SEARCH_API_URL = `https://api.npms.io/v2/`; @@ -275,9 +275,19 @@ export enum VIEWS { PERSONAL_SETTINGS = "PersonalSettings", API_SETTINGS = "APISettings", NOT_FOUND = "NotFoundView", + FAKE_DOOR = "ComingSoon", COMMUNITY_NODES = "CommunityNodes", } +export enum FAKE_DOOR_FEATURES { + ENVIRONMENTS = 'environments', + LOGGING = 'logging', + SHARING = 'sharing', +} + +export const ONBOARDING_PROMPT_TIMEBOX = 14; +export const FIRST_ONBOARDING_PROMPT_TIMEOUT = 300000; + export const TEST_PIN_DATA = [ { name: "First item", diff --git a/packages/editor-ui/src/modules/settings.ts b/packages/editor-ui/src/modules/settings.ts index 094e49e360..1556dc9da3 100644 --- a/packages/editor-ui/src/modules/settings.ts +++ b/packages/editor-ui/src/modules/settings.ts @@ -30,6 +30,7 @@ const module: Module = { latestVersion: 0, path: '/', }, + onboardingCallPromptEnabled: false, }, getters: { versionCli(state: ISettingsState) { @@ -83,6 +84,9 @@ const module: Module = { templatesHost: (state): string => { return state.settings.templates.host; }, + isOnboardingCallPromptFeatureEnabled: (state): boolean => { + return state.onboardingCallPromptEnabled; + }, isCommunityNodesFeatureEnabled: (state): boolean => { return state.settings.communityNodesEnabled; }, @@ -99,6 +103,7 @@ const module: Module = { state.api.enabled = settings.publicApi.enabled; state.api.latestVersion = settings.publicApi.latestVersion; state.api.path = settings.publicApi.path; + state.onboardingCallPromptEnabled = settings.onboardingCallPromptEnabled; }, stopShowingSetupPage(state: ISettingsState) { Vue.set(state.userManagement, 'showSetupOnFirstLoad', false); diff --git a/packages/editor-ui/src/modules/ui.ts b/packages/editor-ui/src/modules/ui.ts index 877b1eee81..f8ee310366 100644 --- a/packages/editor-ui/src/modules/ui.ts +++ b/packages/editor-ui/src/modules/ui.ts @@ -1,3 +1,4 @@ +import { applyForOnboardingCall, fetchNextOnboardingPrompt, submitEmailOnSignup } from '@/api/workflow-webhooks'; import { ABOUT_MODAL_KEY, COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY, @@ -19,11 +20,15 @@ import { WORKFLOW_OPEN_MODAL_KEY, WORKFLOW_SETTINGS_MODAL_KEY, VIEWS, + ONBOARDING_CALL_SIGNUP_MODAL_KEY, + FAKE_DOOR_FEATURES, COMMUNITY_PACKAGE_MANAGE_ACTIONS, } from '@/constants'; import Vue from 'vue'; import { ActionContext, Module } from 'vuex'; import { + IFakeDoor, + IFakeDoorLocation, IRootState, IRunDataDisplayMode, IUiState, @@ -61,6 +66,9 @@ const module: Module = { [DUPLICATE_MODAL_KEY]: { open: false, }, + [ONBOARDING_CALL_SIGNUP_MODAL_KEY]: { + open: false, + }, [PERSONALIZATION_MODAL_KEY]: { open: false, }, @@ -117,6 +125,36 @@ const module: Module = { mappingTelemetry: {}, }, mainPanelPosition: 0.5, + fakeDoorFeatures: [ + { + id: FAKE_DOOR_FEATURES.ENVIRONMENTS, + featureName: 'fakeDoor.settings.environments.name', + icon: 'server', + infoText: 'fakeDoor.settings.environments.infoText', + actionBoxTitle: 'fakeDoor.settings.environments.actionBox.title', + actionBoxDescription: 'fakeDoor.settings.environments.actionBox.description', + linkURL: 'https://n8n-community.typeform.com/to/l7QOrERN#f=environments', + uiLocations: ['settings'], + }, + { + id: FAKE_DOOR_FEATURES.LOGGING, + featureName: 'fakeDoor.settings.logging.name', + icon: 'sign-in-alt', + infoText: 'fakeDoor.settings.logging.infoText', + actionBoxTitle: 'fakeDoor.settings.logging.actionBox.title', + actionBoxDescription: 'fakeDoor.settings.logging.actionBox.description', + linkURL: 'https://n8n-community.typeform.com/to/l7QOrERN#f=logging', + uiLocations: ['settings'], + }, + { + id: FAKE_DOOR_FEATURES.SHARING, + featureName: 'fakeDoor.credentialEdit.sharing.name', + actionBoxTitle: 'fakeDoor.credentialEdit.sharing.actionBox.title', + actionBoxDescription: 'fakeDoor.credentialEdit.sharing.actionBox.description', + linkURL: 'https://n8n-community.typeform.com/to/l7QOrERN#f=sharing', + uiLocations: ['credentialsModal'], + }, + ], draggable: { isDragging: false, type: '', @@ -153,6 +191,13 @@ const module: Module = { outputPanelDispalyMode: (state: IUiState) => state.ndv.output.displayMode, outputPanelEditMode: (state: IUiState): IUiState['ndv']['output']['editMode'] => state.ndv.output.editMode, mainPanelPosition: (state: IUiState) => state.mainPanelPosition, + getFakeDoorFeatures: (state: IUiState) => state.fakeDoorFeatures, + getFakeDoorByLocation: (state: IUiState) => (location: IFakeDoorLocation) => { + return state.fakeDoorFeatures.filter(fakeDoor => fakeDoor.uiLocations.includes(location)); + }, + getFakeDoorById: (state: IUiState) => (id: string) => { + return state.fakeDoorFeatures.find(fakeDoor => fakeDoor.id.toString() === id); + }, focusedMappableInput: (state: IUiState) => state.ndv.focusedMappableInput, isDraggableDragging: (state: IUiState) => state.draggable.isDragging, draggableType: (state: IUiState) => state.draggable.type, @@ -264,6 +309,21 @@ const module: Module = { context.commit('setMode', { name: CREDENTIAL_EDIT_MODAL_KEY, mode: 'new' }); context.commit('openModal', CREDENTIAL_EDIT_MODAL_KEY); }, + getNextOnboardingPrompt: async (context: ActionContext) => { + const instanceId = context.rootGetters.instanceId; + const currentUser = context.rootGetters['users/currentUser']; + return await fetchNextOnboardingPrompt(instanceId, currentUser); + }, + applyForOnboardingCall: async (context: ActionContext, { email }) => { + const instanceId = context.rootGetters.instanceId; + const currentUser = context.rootGetters['users/currentUser']; + return await applyForOnboardingCall(instanceId, currentUser, email); + }, + submitContactEmail: async (context: ActionContext, { email, agree }) => { + const instanceId = context.rootGetters.instanceId; + const currentUser = context.rootGetters['users/currentUser']; + return await submitEmailOnSignup(instanceId, currentUser, email, agree); + }, async openCommunityPackageUninstallConfirmModal(context: ActionContext, packageName: string) { context.commit('setActiveId', { name: COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY, id: packageName}); context.commit('setMode', { name: COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY, mode: COMMUNITY_PACKAGE_MANAGE_ACTIONS.UNINSTALL }); diff --git a/packages/editor-ui/src/modules/userHelpers.ts b/packages/editor-ui/src/modules/userHelpers.ts index 05a6d23bf7..44ba0b528e 100644 --- a/packages/editor-ui/src/modules/userHelpers.ts +++ b/packages/editor-ui/src/modules/userHelpers.ts @@ -108,6 +108,16 @@ export function getPersonalizedNodeTypes(answers: IPersonalizationSurveyAnswersV return getPersonalizationV1(answers as IPersonalizationSurveyAnswersV1); } +export function getAccountAge(currentUser: IUser): number { + if(currentUser.createdAt) { + const accountCreatedAt = new Date(currentUser.createdAt); + const today = new Date(); + + return Math.ceil((today.getTime() - accountCreatedAt.getTime()) / (1000* 3600 * 24)); + } + return -1; +} + function getPersonalizationV2(answers: IPersonalizationSurveyAnswersV2) { let nodeTypes: string[] = []; diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index 6e94459cfa..1cbdca6fd9 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -18,6 +18,8 @@ "generic.clickToCopy": "Click to copy", "generic.copiedToClipboard": "Copied to clipboard", "generic.beta": "beta", + "generic.yes": "Yes", + "generic.no": "No", "about.aboutN8n": "About n8n", "about.close": "Close", "about.license": "License", @@ -55,6 +57,7 @@ "auth.role": "Role", "auth.roles.member": "Member", "auth.roles.owner": "Owner", + "auth.agreement.label": "I’d be OK sharing my opinion on n8n (no marketing emails though)", "auth.setup.confirmOwnerSetup": "Set up owner account?", "auth.setup.confirmOwnerSetupMessage": "To give others access to your {entities}, you’ll need to share these account details with them. Or you can continue as before with no account, by going back and skipping this setup. More info", "auth.setup.createAccount": "Create account", @@ -248,6 +251,18 @@ "expressionEdit.expression": "Expression", "expressionEdit.result": "Result", "expressionEdit.variableSelector": "Variable Selector", + "fakeDoor.credentialEdit.sharing.name": "Sharing", + "fakeDoor.credentialEdit.sharing.actionBox.title": "We're working on sharing (as a paid feature)", + "fakeDoor.credentialEdit.sharing.actionBox.description": "If you'd like to be the first to hear when it's ready, join the list", + "fakeDoor.settings.environments.name": "Environments", + "fakeDoor.settings.environments.infoText": "Environments allow you to use different settings and credentials in a workflow when you're building it vs when it's running in production", + "fakeDoor.settings.environments.actionBox.title": "We're working on this (as a paid feature)", + "fakeDoor.settings.environments.actionBox.description": "If you'd like to be the first to hear when it's ready, join the list.", + "fakeDoor.settings.logging.name": "Logging", + "fakeDoor.settings.logging.infoText": "You can already write logs to a file or the console using environment variables. More info", + "fakeDoor.settings.logging.actionBox.title": "We're working on advanced logging (as a paid feature)", + "fakeDoor.settings.logging.actionBox.description": "This also includes audit logging. If you'd like to be the first to hear when it's ready, join the list.", + "fakeDoor.actionBox.button.label": "Join the list", "fixedCollectionParameter.choose": "Choose...", "fixedCollectionParameter.currentlyNoItemsExist": "Currently no items exist", "fixedCollectionParameter.deleteItem": "Delete item", @@ -556,6 +571,17 @@ "nodeWebhooks.showMessage.title": "URL copied", "nodeWebhooks.testUrl": "Test URL", "nodeWebhooks.webhookUrls": "Webhook URLs", + "onboardingCallSignupModal.title": "Your onboarding session", + "onboardingCallSignupModal.description": "Pop in your email and we'll send you some scheduling options", + "onboardingCallSignupModal.emailInput.placeholder": "Your work email", + "onboardingCallSignupModal.signupButton.label": "Submit", + "onboardingCallSignupModal.cancelButton.label": "Cancel", + "onboardingCallSignupModal.infoText.emailError": "This doesn't seem to be a valid email address", + "onboardingCallSignupSucess.title": "Successfully signed up for an onboarding session", + "onboardingCallSignupSucess.message": "You should receive a message from us shortly", + "onboardingCallSignupFailed.title": "Something went wrong", + "onboardingCallSignupFailed.message": "Your request could not be sent", + "onboardingCallSignupModal.confirmExit.title": "Are you sure?", "onboardingWorkflow.stickyContent": "## 👇 Get started faster \nLightning tour of the key concepts \n\n[![n8n quickstart video](/static/quickstart_thumbnail.png#full-width)](https://www.youtube.com/watch?v=RpjQTGKm-ok)", "openWorkflow.workflowImportError": "Could not import workflow", "openWorkflow.workflowNotFoundError": "Could not find workflow", diff --git a/packages/editor-ui/src/router.ts b/packages/editor-ui/src/router.ts index cc1d05f8a1..086116d2b9 100644 --- a/packages/editor-ui/src/router.ts +++ b/packages/editor-ui/src/router.ts @@ -10,6 +10,7 @@ import SettingsPersonalView from './views/SettingsPersonalView.vue'; import SettingsUsersView from './views/SettingsUsersView.vue'; import SettingsCommunityNodesView from './views/SettingsCommunityNodesView.vue'; import SettingsApiView from './views/SettingsApiView.vue'; +import SettingsFakeDoorView from './views/SettingsFakeDoorView.vue'; import SetupView from './views/SetupView.vue'; import SigninView from './views/SigninView.vue'; import SignupView from './views/SignupView.vue'; @@ -328,6 +329,11 @@ const router = new Router({ meta: { telemetry: { pageCategory: 'settings', + getProperties(route: Route, store: Store) { + return { + feature: 'users', + }; + }, }, permissions: { allow: { @@ -350,6 +356,11 @@ const router = new Router({ meta: { telemetry: { pageCategory: 'settings', + getProperties(route: Route, store: Store) { + return { + feature: 'personal', + }; + }, }, permissions: { allow: { @@ -370,6 +381,11 @@ const router = new Router({ meta: { telemetry: { pageCategory: 'settings', + getProperties(route: Route, store: Store) { + return { + feature: 'api', + }; + }, }, permissions: { allow: { @@ -405,6 +421,27 @@ const router = new Router({ }, }, }, + { + path: '/settings/coming-soon/:featureId', + name: VIEWS.FAKE_DOOR, + component: SettingsFakeDoorView, + props: true, + meta: { + telemetry: { + pageCategory: 'settings', + getProperties(route: Route, store: Store) { + return { + feature: route.params['featureId'], + }; + }, + }, + permissions: { + allow: { + loginStatus: [LOGIN_STATUS.LoggedIn], + }, + }, + }, + }, { path: '*', name: VIEWS.NOT_FOUND, @@ -422,6 +459,7 @@ const router = new Router({ }, permissions: { allow: { + // TODO: Once custom permissions are merged, this needs to be updated with index validation loginStatus: [LOGIN_STATUS.LoggedIn, LOGIN_STATUS.LoggedOut], }, }, diff --git a/packages/editor-ui/src/views/AuthView.vue b/packages/editor-ui/src/views/AuthView.vue index 8b5558dbf9..8fc53ce988 100644 --- a/packages/editor-ui/src/views/AuthView.vue +++ b/packages/editor-ui/src/views/AuthView.vue @@ -84,3 +84,10 @@ body { } + diff --git a/packages/editor-ui/src/views/NodeView.vue b/packages/editor-ui/src/views/NodeView.vue index e2c1e9a5a0..6374ab0abb 100644 --- a/packages/editor-ui/src/views/NodeView.vue +++ b/packages/editor-ui/src/views/NodeView.vue @@ -154,7 +154,25 @@ import { } from 'jsplumb'; import { MessageBoxInputData } from 'element-ui/types/message-box'; import { jsPlumb, OnConnectionBindInfo } from 'jsplumb'; -import { DEFAULT_STICKY_HEIGHT, DEFAULT_STICKY_WIDTH, MODAL_CANCEL, MODAL_CLOSE, MODAL_CONFIRMED, NODE_NAME_PREFIX, NODE_OUTPUT_DEFAULT_KEY, PLACEHOLDER_EMPTY_WORKFLOW_ID, QUICKSTART_NOTE_NAME, START_NODE_TYPE, STICKY_NODE_TYPE, VIEWS, WEBHOOK_NODE_TYPE, WORKFLOW_OPEN_MODAL_KEY } from '@/constants'; +import { + DEFAULT_STICKY_HEIGHT, + DEFAULT_STICKY_WIDTH, + FIRST_ONBOARDING_PROMPT_TIMEOUT, + MODAL_CANCEL, + MODAL_CLOSE, + MODAL_CONFIRMED, + NODE_NAME_PREFIX, + NODE_OUTPUT_DEFAULT_KEY, + ONBOARDING_CALL_SIGNUP_MODAL_KEY, + ONBOARDING_PROMPT_TIMEBOX, + PLACEHOLDER_EMPTY_WORKFLOW_ID, + QUICKSTART_NOTE_NAME, + START_NODE_TYPE, + STICKY_NODE_TYPE, + VIEWS, + WEBHOOK_NODE_TYPE, + WORKFLOW_OPEN_MODAL_KEY, +} from '@/constants'; import { copyPaste } from '@/components/mixins/copyPaste'; import { externalHooks } from '@/components/mixins/externalHooks'; import { genericHelpers } from '@/components/mixins/genericHelpers'; @@ -215,11 +233,12 @@ import { mapGetters } from 'vuex'; import { addNodeTranslation, - addHeaders, } from '@/plugins/i18n'; import '../plugins/N8nCustomConnectorType'; import '../plugins/PlusEndpointType'; +import { getAccountAge } from '@/modules/userHelpers'; +import { IUser } from 'n8n-design-system'; import {dataPinningEventBus} from "@/event-bus/data-pinning-event-bus"; interface AddNodeOptions { @@ -312,9 +331,15 @@ export default mixins( } }, computed: { + ...mapGetters('users', [ + 'currentUser', + ]), ...mapGetters('ui', [ 'sidebarMenuCollapsed', ]), + ...mapGetters('settings', [ + 'isOnboardingCallPromptFeatureEnabled', + ]), defaultLocale (): string { return this.$store.getters.defaultLocale; }, @@ -3060,6 +3085,35 @@ export default mixins( this.$externalHooks().run('nodeView.mount'); + if ( + this.currentUser.personalizationAnswers !== null && + this.isOnboardingCallPromptFeatureEnabled && + getAccountAge(this.currentUser) <= ONBOARDING_PROMPT_TIMEBOX + ) { + const onboardingResponse = await this.$store.dispatch('ui/getNextOnboardingPrompt'); + const promptTimeout = onboardingResponse.toast_sequence_number === 1 ? FIRST_ONBOARDING_PROMPT_TIMEOUT : 1000; + + if (onboardingResponse.title && onboardingResponse.description) { + setTimeout(async () => { + this.$showToast({ + type: 'info', + title: onboardingResponse.title, + message: onboardingResponse.description, + duration: 0, + customClass: 'clickable', + closeOnClick: true, + onClick: () => { + this.$telemetry.track('user clicked onboarding toast', { + seq_num: onboardingResponse.toast_sequence_number, + title: onboardingResponse.title, + description: onboardingResponse.description, + }); + this.$store.commit('ui/openModal', ONBOARDING_CALL_SIGNUP_MODAL_KEY, {root: true}); + }, + }); + }, promptTimeout); + } + } dataPinningEventBus.$on('pin-data', this.addPinDataConnections); dataPinningEventBus.$on('unpin-data', this.removePinDataConnections); }, diff --git a/packages/editor-ui/src/views/SettingsFakeDoorView.vue b/packages/editor-ui/src/views/SettingsFakeDoorView.vue new file mode 100644 index 0000000000..7284c08141 --- /dev/null +++ b/packages/editor-ui/src/views/SettingsFakeDoorView.vue @@ -0,0 +1,39 @@ + + + + + + + + + diff --git a/packages/editor-ui/src/views/SetupView.vue b/packages/editor-ui/src/views/SetupView.vue index d67966bde1..ab508f52db 100644 --- a/packages/editor-ui/src/views/SetupView.vue +++ b/packages/editor-ui/src/views/SetupView.vue @@ -79,6 +79,13 @@ export default mixins( capitalize: true, }, }, + { + name: 'agree', + properties: { + label: this.$locale.baseText('auth.agreement.label'), + type: 'checkbox', + }, + }, ], }; @@ -130,7 +137,7 @@ export default mixins( this.$locale.baseText('auth.setup.goBack'), ); }, - async onSubmit(values: {[key: string]: string}) { + async onSubmit(values: {[key: string]: string | boolean}) { try { const confirmSetup = await this.confirmSetupOrGoBack(); if (!confirmSetup) { @@ -140,6 +147,13 @@ export default mixins( const forceRedirectedHere = this.$store.getters['settings/showSetupPage']; this.loading = true; await this.$store.dispatch('users/createOwner', values); + + if (values.agree === true) { + try { + await this.$store.dispatch('ui/submitContactEmail', { email: values.email, agree: values.agree }); + } catch { } + } + if (forceRedirectedHere) { await this.$router.push({ name: VIEWS.HOMEPAGE }); } diff --git a/packages/editor-ui/src/views/SignupView.vue b/packages/editor-ui/src/views/SignupView.vue index 80d95b79f3..9553a2b566 100644 --- a/packages/editor-ui/src/views/SignupView.vue +++ b/packages/editor-ui/src/views/SignupView.vue @@ -59,6 +59,13 @@ export default mixins( capitalize: true, }, }, + { + name: 'agree', + properties: { + label: this.$locale.baseText('auth.agreement.label'), + type: 'checkbox', + }, + }, ], }; return { @@ -95,13 +102,19 @@ export default mixins( }, }, methods: { - async onSubmit(values: {[key: string]: string}) { + async onSubmit(values: {[key: string]: string | boolean}) { try { this.loading = true; const inviterId = this.$route.query.inviterId; const inviteeId = this.$route.query.inviteeId; await this.$store.dispatch('users/signup', {...values, inviterId, inviteeId}); + if (values.agree === true) { + try { + await this.$store.dispatch('ui/submitContactEmail', { email: values.email, agree: values.agree }); + } catch { } + } + await this.$router.push({ name: VIEWS.HOMEPAGE }); } catch (error) { this.$showError(error, this.$locale.baseText('auth.signup.setupYourAccountError'));