diff --git a/packages/cli/config/schema.ts b/packages/cli/config/schema.ts index 50111f874a4..6eb277754cd 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 e59cd842382..0b81e71112d 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 b4aeac677d6..f0830e9280b 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 4f64479b920..85e086cd647 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 15ae12e013b..978f8117d70 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 88c4c317804..3ea6b330776 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 3f8b5885118..95dc6513b39 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 3df8d1c0cdc..a86bf5ec241 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 917ee234572..a2f089681d8 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 af9f1bd8b41..a2b2b1bc6ac 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 00000000000..04176dcf5ba --- /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 6930734ac7c..945890bebce 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 00000000000..327643d8c34 --- /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 e8a3259fd95..733fd03986a 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 00000000000..aa57f26dff0 --- /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 0959b741d22..eed74e84f0c 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 13392910041..c90984969d8 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 a6d81a7ba08..61af2391308 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 094e49e3602..1556dc9da30 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 877b1eee81e..f8ee3103661 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 05a6d23bf70..44ba0b528eb 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 6e94459cfad..1cbdca6fd98 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[](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 cc1d05f8a1c..086116d2b99 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 8b5558dbf98..8fc53ce9887 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 e2c1e9a5a0d..6374ab0abb2 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 00000000000..7284c081417 --- /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 d67966bde1d..ab508f52dbc 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 80d95b79f35..9553a2b5661 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'));