diff --git a/.gitignore b/.gitignore index 6696a429b5..eab6e79ab7 100644 --- a/.gitignore +++ b/.gitignore @@ -62,4 +62,4 @@ CLAUDE.md .opencode/ .claude/ -storybook/yarn.lock \ No newline at end of file +storybook/yarn.lock diff --git a/package.json b/package.json index 06febdbc3e..be5f7bc84c 100644 --- a/package.json +++ b/package.json @@ -114,7 +114,7 @@ "eslint-plugin-xss": "^0.1.12", "husky": "^8.0.0", "jsdom": "^22.1.0", - "postcss": "^8.4.31", + "postcss": "^8.5.8", "prettier": "^3.0.0", "sass": "1.77.6", "start-server-and-test": "^2.0.0", diff --git a/src/App.vue b/src/App.vue index 7a6433ad72..b7d12f2963 100644 --- a/src/App.vue +++ b/src/App.vue @@ -6,10 +6,14 @@ import { useThemeStore, DARK_SCHEME_QUERY } from '@/stores/theme' import { storeToRefs } from 'pinia' import { themeApply } from '@/helpers' + import { captureFirstSessionUrl } from '@/helpers/first-session-url' import Layout from '@/layout' import '@modules/real-time-metrics/helpers/convert-date' import '@/helpers/store-handler' + // Capture the first session URL as early as possible + captureFirstSessionUrl() + const DEFAULT_TITLE = 'Azion Console' /** @type {import('@/plugins/analytics/AnalyticsTrackerAdapter').AnalyticsTrackerAdapter} */ diff --git a/src/helpers/first-session-url.js b/src/helpers/first-session-url.js new file mode 100644 index 0000000000..126a201c4e --- /dev/null +++ b/src/helpers/first-session-url.js @@ -0,0 +1,37 @@ +const FIRST_SESSION_URL_KEY = 'azion_first_session_url' + +/** + * Captures the first session URL and stores it in localStorage. + * This should be called early in the application lifecycle. + * The URL is only stored once per user session. + */ +export const captureFirstSessionUrl = () => { + if (typeof window === 'undefined') return null + + // Only capture if not already stored + if (!localStorage.getItem(FIRST_SESSION_URL_KEY)) { + const currentUrl = window.location.href + localStorage.setItem(FIRST_SESSION_URL_KEY, currentUrl) + return currentUrl + } + + return getFirstSessionUrl() +} + +/** + * Retrieves the first session URL from localStorage. + * @returns {string|null} The first session URL or null if not set. + */ +export const getFirstSessionUrl = () => { + if (typeof window === 'undefined') return null + return localStorage.getItem(FIRST_SESSION_URL_KEY) +} + +/** + * Clears the first session URL from localStorage. + * This can be called after the signup flow is complete. + */ +export const clearFirstSessionUrl = () => { + if (typeof window === 'undefined') return + localStorage.removeItem(FIRST_SESSION_URL_KEY) +} diff --git a/src/plugins/analytics/trackers/SignInTracker.js b/src/plugins/analytics/trackers/SignInTracker.js index b2afb7a165..35cdb88349 100644 --- a/src/plugins/analytics/trackers/SignInTracker.js +++ b/src/plugins/analytics/trackers/SignInTracker.js @@ -1,3 +1,24 @@ +import { hubspotFormSubmitService } from '@/services/hubspot-services' + +/** + * Maps signin type flags to the appropriate form_action value for HubSpot. + * @param {Object} signupTypeFlags - The signin type flags + * @returns {string} The form_action value + */ +const FORM_ACTION_PRIORITY = [ + 'login_sso_google', + 'login_sso_github', + 'login_email', + 'signup_sso_google', + 'signup_sso_github', + 'signup_email' +] + +const DEFAULT_FORM_ACTION = 'login_email' + +const getFormAction = (signupTypeFlags) => + FORM_ACTION_PRIORITY.find((action) => signupTypeFlags?.[action]) ?? DEFAULT_FORM_ACTION + export class SignInTracker { /** * Interface for TrackerAdapter. @@ -14,23 +35,89 @@ export class SignInTracker { } /** + * @param {Object} payload + * @param {'google'|'azure'|'github'|'email'} payload.method + * @param {Object} [payload.signupTypeFlags] - Flags for signin type tracking + * @param {string} [payload.email] - User email for HubSpot + * @param {string} [payload.userId] - Console user ID for HubSpot + * @param {string} [payload.firstname] - User first name for HubSpot + * @param {string} [payload.lastname] - User last name for HubSpot + * @param {string} [payload.company] - Company name for HubSpot + * @param {string} [payload.githubHandle] - GitHub handle for HubSpot + * @param {string} [payload.phone] - User phone for HubSpot + * * @returns {AnalyticsTrackerAdapter} */ - userSignedIn() { + userSignedIn(payload) { + // Get form_action from flags for both Segment and HubSpot + const formAction = getFormAction(payload.signupTypeFlags) + this.#trackerAdapter.addEvent({ eventName: 'User Signed In', - props: {} + props: { + method: payload.method, + login_sso_google: payload.signupTypeFlags?.login_sso_google ?? false, + login_sso_github: payload.signupTypeFlags?.login_sso_github ?? false, + login_email: payload.signupTypeFlags?.login_email ?? false, + signup_sso_google: payload.signupTypeFlags?.signup_sso_google ?? false, + signup_sso_github: payload.signupTypeFlags?.signup_sso_github ?? false, + signup_email: payload.signupTypeFlags?.signup_email ?? false + } + }) + + // Submit to HubSpot if email and userId are provided + if (payload.email && payload.userId) { + hubspotFormSubmitService({ + email: payload.email, + form_action: formAction, + user_id__rtm_: payload.userId, + segment_userid: payload.userId, + firstname: payload.firstname, + lastname: payload.lastname, + mobilephone: payload.phone, + company: payload.company, + github_handle: payload.githubHandle + }) + } + + return this.#trackerAdapter + } + + /** + * @param {Object} payload + * @param {'google'|'azure'|'github'|'email'} payload.method + * + * @returns {AnalyticsTrackerAdapter} + */ + userClickedSignIn(payload) { + this.#trackerAdapter.addEvent({ + eventName: 'User Clicked to Sign In', + props: { + method: payload.method + } }) return this.#trackerAdapter } /** + * @param {Object} payload + * @param {'google'|'azure'|'github'|'email'} payload.method + * @param {Object} [payload.signupTypeFlags] - Flags for signup type tracking + * * @returns {AnalyticsTrackerAdapter} */ - userFailedSignIn() { + userFailedSignIn(payload) { this.#trackerAdapter.addEvent({ eventName: 'User Failed to Sign In', - props: {} + props: { + method: payload.method, + login_sso_google: payload.signupTypeFlags?.login_sso_google ?? false, + login_sso_github: payload.signupTypeFlags?.login_sso_github ?? false, + login_email: payload.signupTypeFlags?.login_email ?? false, + signup_sso_google: payload.signupTypeFlags?.signup_sso_google ?? false, + signup_sso_github: payload.signupTypeFlags?.signup_sso_github ?? false, + signup_email: payload.signupTypeFlags?.signup_email ?? false + } }) return this.#trackerAdapter } diff --git a/src/plugins/analytics/trackers/SignUpTracker.js b/src/plugins/analytics/trackers/SignUpTracker.js index 3582e53f59..4f8504827d 100644 --- a/src/plugins/analytics/trackers/SignUpTracker.js +++ b/src/plugins/analytics/trackers/SignUpTracker.js @@ -1,8 +1,25 @@ +import { hubspotFormSubmitService } from '@/services/hubspot-services' + +const FORM_ACTION_PRIORITY = [ + 'signup_sso_google', + 'signup_sso_github', + 'signup_email', + 'login_sso_google', + 'login_sso_github', + 'login_email' +] + +const DEFAULT_FORM_ACTION = 'signup_email' + +const getFormAction = (signupTypeFlags) => + FORM_ACTION_PRIORITY.find((action) => signupTypeFlags?.[action]) ?? DEFAULT_FORM_ACTION + export class SignUpTracker { /** * Interface for TrackerAdapter. * @typedef {Object} trackerAdapter * @property {function({eventName: string, props: Object}): void} addEvent - Method to add an event. + * @property {function(): Object} getUserContext - Method to get user context for HubSpot. */ #trackerAdapter @@ -16,16 +33,53 @@ export class SignUpTracker { /** * @param {Object} payload * @param {'google'|'azure'|'github'|'email'} payload.method + * @param {string} [payload.firstSessionUrl] - The first session URL + * @param {Object} [payload.signupTypeFlags] - Flags for signup type tracking + * @param {string} [payload.email] - User email for HubSpot + * @param {string} [payload.userId] - Console user ID for HubSpot + * @param {string} [payload.firstname] - User first name for HubSpot + * @param {string} [payload.lastname] - User last name for HubSpot + * @param {string} [payload.company] - Company name for HubSpot + * @param {string} [payload.githubHandle] - GitHub handle for HubSpot + * @param {string} [payload.phone] - User phone for HubSpot * * @returns {AnalyticsTrackerAdapter} */ userSignedUp(payload) { + // Get signup_type from flags for both Segment and HubSpot + const signupType = getFormAction(payload.signupTypeFlags) + + // Add Segment event this.#trackerAdapter.addEvent({ eventName: 'User Signed Up', props: { - method: payload.method + method: payload.method, + first_session_url: payload.firstSessionUrl, + signup_type: signupType, + login_sso_google: payload.signupTypeFlags?.login_sso_google ?? false, + login_sso_github: payload.signupTypeFlags?.login_sso_github ?? false, + login_email: payload.signupTypeFlags?.login_email ?? false, + signup_sso_google: payload.signupTypeFlags?.signup_sso_google ?? false, + signup_sso_github: payload.signupTypeFlags?.signup_sso_github ?? false, + signup_email: payload.signupTypeFlags?.signup_email ?? false } }) + + // Submit to HubSpot if email and userId are provided + if (payload.email && payload.userId) { + hubspotFormSubmitService({ + email: payload.email, + form_action: signupType, + user_id__rtm_: payload.userId, + segment_userid: payload.userId, + firstname: payload.firstname, + lastname: payload.lastname, + mobilephone: payload.phone, + company: payload.company, + github_handle: payload.githubHandle + }) + } + return this.#trackerAdapter } @@ -48,14 +102,27 @@ export class SignUpTracker { /** * @param {Object} payload * @param {'google'|'azure'|'github'} payload.method + * @param {string} [payload.firstSessionUrl] - The first session URL + * @param {Object} [payload.signupTypeFlags] - Flags for signup type tracking * * @returns {AnalyticsTrackerAdapter} */ userAuthorizedSso(payload) { + // Get signup_type from flags for consistency + const signupType = getFormAction(payload.signupTypeFlags) + this.#trackerAdapter.addEvent({ eventName: 'User Authorized SSO', props: { - method: payload.method + method: payload.method, + first_session_url: payload.firstSessionUrl, + signup_type: signupType, + login_sso_google: payload.signupTypeFlags?.login_sso_google ?? false, + login_sso_github: payload.signupTypeFlags?.login_sso_github ?? false, + login_email: payload.signupTypeFlags?.login_email ?? false, + signup_sso_google: payload.signupTypeFlags?.signup_sso_google ?? false, + signup_sso_github: payload.signupTypeFlags?.signup_sso_github ?? false, + signup_email: payload.signupTypeFlags?.signup_email ?? false } }) return this.#trackerAdapter diff --git a/src/router/routes/signup-routes/index.js b/src/router/routes/signup-routes/index.js index f12ec5e771..392ca07f8b 100644 --- a/src/router/routes/signup-routes/index.js +++ b/src/router/routes/signup-routes/index.js @@ -2,6 +2,7 @@ import * as SignupService from '@/services/signup-services' import { inject } from 'vue' import { useAccountStore } from '@/stores/account' import SignupView from '@/views/Signup/SignupView.vue' +import { getFirstSessionUrl } from '@/helpers/first-session-url' /** @type {import('vue-router').RouteRecordRaw} */ export const signupRoutes = { @@ -38,13 +39,50 @@ export const signupRoutes = { beforeEnter: (__, ___, next) => { const accountStore = useAccountStore() const isFirstLogin = accountStore.isFirstLogin + const signupTypeFlags = accountStore.getSignupTypeFlags() - if (isFirstLogin && accountStore.ssoSignUpMethod) { + // Check if this is a first login with signup tracking needed + const isEmailSignup = signupTypeFlags.signup_email + const isSsoSignup = accountStore.ssoSignUpMethod + + if (isFirstLogin && (isSsoSignup || isEmailSignup)) { /** @type {import('@/plugins/adapters/AnalyticsTrackerAdapter').AnalyticsTrackerAdapter} */ const tracker = inject('tracker') - const signUpMethod = { method: accountStore.ssoSignUpMethod } - tracker.signUp.userSignedUp(signUpMethod).signUp.userAuthorizedSso(signUpMethod) + // Determine the method for tracking + const method = isSsoSignup || 'email' + + // Get user data for HubSpot tracking + const { userId: consoleUserId, accountData } = accountStore + const userEmail = accountData?.email + const userName = accountData?.name || '' + const companyName = accountData?.company_name || '' + + // Parse first and last name from full name + const nameParts = userName.split(' ') + const firstname = nameParts[0] || '' + const lastname = nameParts.slice(1).join(' ') || '' + + const signUpPayload = { + method, + firstSessionUrl: getFirstSessionUrl(), + signupTypeFlags, + // HubSpot required fields + email: userEmail, + userId: consoleUserId, + firstname, + lastname, + company: companyName, + githubHandle: method === 'github' ? accountData?.github_handle : undefined + } + + // Track user signup with Segment and HubSpot + tracker.signUp.userSignedUp(signUpPayload) + + // For SSO, also track the SSO authorization event + if (isSsoSignup) { + tracker.signUp.userAuthorizedSso(signUpPayload) + } } if (isFirstLogin) { diff --git a/src/router/routes/switch-account-routes/index.js b/src/router/routes/switch-account-routes/index.js index 0526773da0..ab4042de75 100644 --- a/src/router/routes/switch-account-routes/index.js +++ b/src/router/routes/switch-account-routes/index.js @@ -1,6 +1,9 @@ import { AccountHandler } from '@/helpers/account-handler' import * as AuthServices from '@/services/auth-services' import { listTypeAccountService } from '@/services/switch-account-services/list-type-account-service' +import { useAccountStore } from '@/stores/account' +import { loadUserAndAccountInfo } from '@/helpers/account-data' +import { inject } from 'vue' /** @type {import('vue-router').RouteRecordRaw} */ export const switchAccountRoutes = { @@ -16,6 +19,9 @@ export const switchAccountRoutes = { ) const verify = AuthServices.verifyAuthenticationService const refresh = AuthServices.refreshAuthenticationService + /** @type {import('@/plugins/analytics/AnalyticsTrackerAdapter').AnalyticsTrackerAdapter} */ + const tracker = inject('tracker') + const accountStore = useAccountStore() try { const EnableSocialLogin = true @@ -24,6 +30,35 @@ export const switchAccountRoutes = { refresh, EnableSocialLogin ) + + // Track SSO sign-in for returning users (not first login) + const signupTypeFlags = accountStore.getSignupTypeFlags() + const isSsoLogin = signupTypeFlags.login_sso_google || signupTypeFlags.login_sso_github + + if (isSsoLogin) { + // Load user data to check firstLogin status and for HubSpot tracking + await loadUserAndAccountInfo() + const { userId: consoleUserId, accountData, isFirstLogin } = accountStore + + // Only track sign-in for returning users (first login is tracked in signup-routes) + if (!isFirstLogin) { + const ssoMethod = signupTypeFlags.login_sso_google ? 'google' : 'github' + + tracker.signIn + .userSignedIn({ + method: ssoMethod, + signupTypeFlags, + email: accountData?.email, + userId: consoleUserId, + firstname: accountData?.first_name, + lastname: accountData?.last_name, + company: accountData?.company_name, + githubHandle: ssoMethod === 'github' ? accountData?.github_handle : undefined + }) + .track() + } + } + next(redirect) } catch { next({ name: 'login' }) diff --git a/src/services/hubspot-services/hubspot-form-submit-service.js b/src/services/hubspot-services/hubspot-form-submit-service.js new file mode 100644 index 0000000000..0ddc7a6eae --- /dev/null +++ b/src/services/hubspot-services/hubspot-form-submit-service.js @@ -0,0 +1,113 @@ +import { AxiosHttpClientAdapter } from '../axios/AxiosHttpClientAdapter' + +const HUBSPOT_FORM_ID = 'b37e4a6a-8808-4e53-862a-7f82728138b9' +const HUBSPOT_PORTAL_ID = '145499104' + +const REQUIRED_FIELDS = ['email', 'form_action', 'user_id__rtm_', 'segment_userid'] + +const OPTIONAL_FIELDS_MAP = { + firstname: 'firstname', + lastname: 'lastname', + mobilephone: 'mobilephone', + company: 'company', + github_handle: 'github_handle' +} + +const VALID_FORM_ACTIONS = [ + 'signup_email', + 'signup_sso_google', + 'signup_sso_github', + 'login_email', + 'login_sso_google', + 'login_sso_github' +] + +/** + * Validates the payload for required fields and valid form_action. + * @param {Object} payload - The form payload + * @throws {Error} If validation fails + */ +function validatePayload(payload) { + const missingFields = REQUIRED_FIELDS.filter((field) => !payload[field]) + + if (missingFields.length > 0) { + throw new Error(`Missing required fields: ${missingFields.join(', ')}`) + } + + if (!VALID_FORM_ACTIONS.includes(payload.form_action)) { + throw new Error( + `Invalid form_action: ${payload.form_action}. Valid values: ${VALID_FORM_ACTIONS.join(', ')}` + ) + } + + if (payload.user_id__rtm_ !== payload.segment_userid) { + throw new Error('user_id__rtm_ must equal segment_userid') + } +} + +/** + * Builds the fields array for HubSpot API from payload. + * @param {Object} payload - The form payload + * @returns {Array} Array of field objects + */ +function buildFields(payload) { + const requiredFields = REQUIRED_FIELDS.map((field) => ({ + name: field, + value: payload[field] + })) + + const optionalFields = Object.entries(OPTIONAL_FIELDS_MAP) + .filter(([key]) => payload[key]) + .map(([key, fieldName]) => ({ + name: fieldName, + value: payload[key] + })) + + return [...requiredFields, ...optionalFields] +} + +/** + * Submits a form to HubSpot Forms API. + * @param {Object} payload - The form payload + * @param {string} payload.email - User email (mandatory) + * @param {string} payload.form_action - Form action type (mandatory): signup_email | signup_sso_google | signup_sso_github | login_email | login_sso_google | login_sso_github + * @param {string} payload.user_id__rtm_ - Console user ID (mandatory, must equal segment_userid) + * @param {string} payload.segment_userid - Console user ID (mandatory) + * @param {string} [payload.firstname] - User first name + * @param {string} [payload.lastname] - User last name + * @param {string} [payload.mobilephone] - User phone + * @param {string} [payload.company] - Company name + * @param {string} [payload.github_handle] - GitHub handle + * @returns {Promise<{success: boolean, error?: string}>} + */ +export const hubspotFormSubmitService = async (payload) => { + try { + validatePayload(payload) + + const fields = buildFields(payload) + const url = `https://api.hsforms.com/submissions/v3/integration/submit/${HUBSPOT_PORTAL_ID}/${HUBSPOT_FORM_ID}` + + await AxiosHttpClientAdapter.request({ + url, + method: 'POST', + body: { fields }, + headers: { + 'Content-Type': 'application/json' + } + }) + + return { success: true } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + + // Log error but don't block the signup flow + // eslint-disable-next-line no-console + console.warn('HubSpot form submission failed:', { + error: errorMessage, + email: payload.email, + form_action: payload.form_action + }) + + return { success: false, error: errorMessage } + } +} diff --git a/src/services/hubspot-services/index.js b/src/services/hubspot-services/index.js new file mode 100644 index 0000000000..0263276df5 --- /dev/null +++ b/src/services/hubspot-services/index.js @@ -0,0 +1,3 @@ +import { hubspotFormSubmitService } from './hubspot-form-submit-service' + +export { hubspotFormSubmitService } diff --git a/src/stores/account.js b/src/stores/account.js index 56ee7761c1..666584325f 100644 --- a/src/stores/account.js +++ b/src/stores/account.js @@ -3,12 +3,20 @@ import { defineStore } from 'pinia' export const useAccountStore = defineStore({ id: 'account', persist: { - paths: ['identifySignUpProvider', 'hasSession'] + paths: ['identifySignUpProvider', 'hasSession', 'signupTypeFlags'] }, state: () => ({ account: {}, hasSession: false, identifySignUpProvider: '', + signupTypeFlags: { + login_sso_google: false, + login_sso_github: false, + login_email: false, + signup_sso_google: false, + signup_sso_github: false, + signup_email: false + }, accountStatuses: { BLOCKED: 'BLOCKED', DEFAULTING: 'DEFAULTING', @@ -146,12 +154,36 @@ export const useAccountStore = defineStore({ this.account = {} this.hasSession = false this.identifySignUpProvider = '' + this.signupTypeFlags = { + login_sso_google: false, + login_sso_github: false, + login_email: false, + signup_sso_google: false, + signup_sso_github: false, + signup_email: false + } }, setSsoSignUpMethod(method) { this.identifySignUpProvider = method }, resetSsoSignUpMethod() { this.identifySignUpProvider = '' + }, + /** + * Sets a signup type flag to true. + * @param {string} flag - The flag name (e.g., 'signup_sso_google', 'login_email') + */ + setSignupTypeFlag(flag) { + if (flag in this.signupTypeFlags) { + this.signupTypeFlags[flag] = true + } + }, + /** + * Gets all signup type flags. + * @returns {Object} The signup type flags object + */ + getSignupTypeFlags() { + return { ...this.signupTypeFlags } } } }) diff --git a/src/templates/sign-in-block/index.vue b/src/templates/sign-in-block/index.vue index 176cf99b67..3ec38cec41 100644 --- a/src/templates/sign-in-block/index.vue +++ b/src/templates/sign-in-block/index.vue @@ -77,7 +77,10 @@ - + @@ -160,11 +163,15 @@ import { useField, useForm } from 'vee-validate' import { ref, inject, onMounted, computed, nextTick } from 'vue' import { useRoute, useRouter } from 'vue-router' + import { useAccountStore } from '@/stores/account' + import { loadUserAndAccountInfo } from '@/helpers/account-data' + import Divider from '@aziontech/webkit/divider' import * as yup from 'yup' import { useToast } from '@aziontech/webkit/use-toast' /**@type {import('@/plugins/analytics/AnalyticsTrackerAdapter').AnalyticsTrackerAdapter} */ const tracker = inject('tracker') + const accountStore = useAccountStore() defineOptions({ name: 'signInBlock' }) @@ -265,7 +272,22 @@ await props.authenticationLoginService(loginData) const { twoFactor, trustedDevice, user_tracking_info: userInfo } = await verify() - tracker.signIn.userSignedIn() + const signupTypeFlags = accountStore.getSignupTypeFlags() + + // Load user and account info to populate accountStore for HubSpot tracking + await loadUserAndAccountInfo() + const { userId: consoleUserId, accountData } = accountStore + tracker.signIn + .userSignedIn({ + method: 'email', + signupTypeFlags, + email: accountData?.email || values.email, + userId: consoleUserId, + firstname: accountData?.first_name || accountData?.name?.split(' ')[0], + lastname: accountData?.last_name || accountData?.name?.split(' ').slice(1).join(' '), + company: accountData?.company_name + }) + .track() if (twoFactor) { const mfaRoute = trustedDevice ? 'authentication' : 'setup' router.push(`/mfa/${mfaRoute}`) @@ -274,7 +296,8 @@ await switchClientAccount(userInfo.props) } catch { - tracker.signIn.userFailedSignIn().track() + const signupTypeFlags = accountStore.getSignupTypeFlags() + tracker.signIn.userFailedSignIn({ method: 'email', signupTypeFlags }).track() hasRequestErrorMessage.value = new UserNotFoundError().message isButtonLoading.value = false } diff --git a/src/templates/signup-block/login-with-email-block.vue b/src/templates/signup-block/login-with-email-block.vue index 2a9f9e0d48..dbcc15d76b 100644 --- a/src/templates/signup-block/login-with-email-block.vue +++ b/src/templates/signup-block/login-with-email-block.vue @@ -183,6 +183,9 @@ const handleSignUpEmailClick = () => { const accountStore = useAccountStore() accountStore.resetSsoSignUpMethod() + // Set signup_email flag when user chooses email signup + accountStore.setSignupTypeFlag('login_email') + accountStore.setSignupTypeFlag('signup_email') showForm.value = true labelButton.value = 'Sign Up' } diff --git a/src/templates/social-idps-block/index.vue b/src/templates/social-idps-block/index.vue index ddd188422c..0623c4cff6 100644 --- a/src/templates/social-idps-block/index.vue +++ b/src/templates/social-idps-block/index.vue @@ -44,6 +44,14 @@ const tracker = inject('tracker') + const props = defineProps({ + context: { + type: String, + default: 'signup', + validator: (value) => ['signup', 'login'].includes(value) + } + }) + const idps = ref([]) const submittedIdp = ref(null) @@ -86,8 +94,17 @@ if (validateOAuthRedirect(idp.loginUrl)) { accountStore.setSsoSignUpMethod(idp.slug) + // Set the login_sso flag based on provider + const loginFlag = `login_sso_${idp.slug}` + accountStore.setSignupTypeFlag(loginFlag) window.location.assign(idp.loginUrl) - tracker.signUp.userClickedSignedUp({ method: idp.slug }).track() + + // Track based on context (signup or login) + if (props.context === 'login') { + tracker.signIn.userClickedSignIn({ method: idp.slug }).track() + } else { + tracker.signUp.userClickedSignedUp({ method: idp.slug }).track() + } } else { loadingStore.finishLoading() submittedIdp.value = null diff --git a/src/tests/helpers/first-session-url.test.js b/src/tests/helpers/first-session-url.test.js new file mode 100644 index 0000000000..6e70ef5d76 --- /dev/null +++ b/src/tests/helpers/first-session-url.test.js @@ -0,0 +1,151 @@ +import { describe, expect, it, beforeEach, vi } from 'vitest' +import { + captureFirstSessionUrl, + getFirstSessionUrl, + clearFirstSessionUrl +} from '@/helpers/first-session-url' + +const FIRST_SESSION_URL_KEY = 'azion_first_session_url' + +describe('first-session-url', () => { + beforeEach(() => { + localStorage.clear() + }) + + describe('captureFirstSessionUrl', () => { + it('should capture and store the current URL on first call', () => { + const testUrl = 'https://console.azion.com/signup?ref=docs' + vi.stubGlobal('location', { href: testUrl }) + + const result = captureFirstSessionUrl() + + expect(result).toBe(testUrl) + expect(localStorage.getItem(FIRST_SESSION_URL_KEY)).toBe(testUrl) + }) + + it('should return existing URL on subsequent calls without overwriting', () => { + const firstUrl = 'https://console.azion.com/first-visit' + const secondUrl = 'https://console.azion.com/different-page' + + // First capture + vi.stubGlobal('location', { href: firstUrl }) + captureFirstSessionUrl() + + // Second capture attempt with different URL + vi.stubGlobal('location', { href: secondUrl }) + const result = captureFirstSessionUrl() + + // Should return the first URL, not overwrite + expect(result).toBe(firstUrl) + expect(localStorage.getItem(FIRST_SESSION_URL_KEY)).toBe(firstUrl) + }) + + it('should return existing URL when called multiple times', () => { + const testUrl = 'https://console.azion.com/signup?utm_source=google' + vi.stubGlobal('location', { href: testUrl }) + + // Call multiple times + const result1 = captureFirstSessionUrl() + const result2 = captureFirstSessionUrl() + const result3 = captureFirstSessionUrl() + + expect(result1).toBe(testUrl) + expect(result2).toBe(testUrl) + expect(result3).toBe(testUrl) + expect(localStorage.getItem(FIRST_SESSION_URL_KEY)).toBe(testUrl) + }) + }) + + describe('getFirstSessionUrl', () => { + it('should return the stored first session URL', () => { + const testUrl = 'https://console.azion.com/signup' + localStorage.setItem(FIRST_SESSION_URL_KEY, testUrl) + + const result = getFirstSessionUrl() + + expect(result).toBe(testUrl) + }) + + it('should return null when no URL has been stored', () => { + const result = getFirstSessionUrl() + + expect(result).toBeNull() + }) + + it('should not modify localStorage when retrieving', () => { + const testUrl = 'https://console.azion.com/signup' + localStorage.setItem(FIRST_SESSION_URL_KEY, testUrl) + + getFirstSessionUrl() + getFirstSessionUrl() + getFirstSessionUrl() + + expect(localStorage.getItem(FIRST_SESSION_URL_KEY)).toBe(testUrl) + }) + }) + + describe('clearFirstSessionUrl', () => { + it('should remove the stored first session URL', () => { + const testUrl = 'https://console.azion.com/signup' + localStorage.setItem(FIRST_SESSION_URL_KEY, testUrl) + + clearFirstSessionUrl() + + expect(localStorage.getItem(FIRST_SESSION_URL_KEY)).toBeNull() + }) + + it('should not throw when no URL is stored', () => { + expect(() => clearFirstSessionUrl()).not.toThrow() + }) + + it('should allow capturing new URL after clearing', () => { + const firstUrl = 'https://console.azion.com/first' + const secondUrl = 'https://console.azion.com/second' + + // Capture first URL + vi.stubGlobal('location', { href: firstUrl }) + captureFirstSessionUrl() + expect(getFirstSessionUrl()).toBe(firstUrl) + + // Clear + clearFirstSessionUrl() + expect(getFirstSessionUrl()).toBeNull() + + // Capture new URL + vi.stubGlobal('location', { href: secondUrl }) + captureFirstSessionUrl() + expect(getFirstSessionUrl()).toBe(secondUrl) + }) + }) + + describe('integration flow', () => { + it('should handle full capture -> retrieve -> clear flow', () => { + const testUrl = 'https://console.azion.com/signup?utm_source=docs' + + // Capture + vi.stubGlobal('location', { href: testUrl }) + captureFirstSessionUrl() + + // Retrieve + expect(getFirstSessionUrl()).toBe(testUrl) + + // Clear + clearFirstSessionUrl() + + // Verify cleared + expect(getFirstSessionUrl()).toBeNull() + }) + + it('should persist URL across multiple getFirstSessionUrl calls', () => { + const testUrl = 'https://console.azion.com/signup?ref=email' + + vi.stubGlobal('location', { href: testUrl }) + captureFirstSessionUrl() + + // Multiple retrievals should return same value + expect(getFirstSessionUrl()).toBe(testUrl) + expect(getFirstSessionUrl()).toBe(testUrl) + expect(getFirstSessionUrl()).toBe(testUrl) + }) + }) +}) diff --git a/src/tests/plugins/analytics/AnalyticsTrackerAdapter.test.js b/src/tests/plugins/analytics/AnalyticsTrackerAdapter.test.js index 7094a9a067..0352cab4d2 100644 --- a/src/tests/plugins/analytics/AnalyticsTrackerAdapter.test.js +++ b/src/tests/plugins/analytics/AnalyticsTrackerAdapter.test.js @@ -208,11 +208,18 @@ describe('AnalyticsTrackerAdapter', () => { it('should call userSigned when valid identification is provided', () => { const { sut, analyticsClientSpy } = makeSut() - sut.signIn.userSignedIn() + sut.signIn.userSignedIn({ method: 'email' }) sut.track() expect(analyticsClientSpy.track).toHaveBeenCalledWith('User Signed In', { + method: 'email', + login_sso_google: false, + login_sso_github: false, + login_email: false, + signup_sso_google: false, + signup_sso_github: false, + signup_email: false, application: fixtures.application }) }) @@ -220,11 +227,18 @@ describe('AnalyticsTrackerAdapter', () => { it('should call userFailedSignIn when valid identification is provided', () => { const { sut, analyticsClientSpy } = makeSut() - sut.signIn.userFailedSignIn() + sut.signIn.userFailedSignIn({ method: 'email' }) sut.track() expect(analyticsClientSpy.track).toHaveBeenCalledWith('User Failed to Sign In', { + method: 'email', + login_sso_google: false, + login_sso_github: false, + login_email: false, + signup_sso_google: false, + signup_sso_github: false, + signup_email: false, application: fixtures.application }) }) @@ -251,10 +265,34 @@ describe('AnalyticsTrackerAdapter', () => { it('should track the user sign-up event with the correct parameters', () => { const { sut, analyticsClientSpy } = makeSut() - sut.signUp.userSignedUp({ method: 'email' }).track() + const signupTypeFlags = { + login_sso_google: false, + login_sso_github: false, + login_email: true, + signup_sso_google: false, + signup_sso_github: false, + signup_email: true + } + + sut.signUp + .userSignedUp({ + method: 'email', + firstSessionUrl: 'https://example.com/signup', + signupType: 'email', + signupTypeFlags + }) + .track() expect(analyticsClientSpy.track).toHaveBeenCalledWith('User Signed Up', { method: 'email', + first_session_url: 'https://example.com/signup', + signup_type: 'signup_email', + login_sso_google: false, + login_sso_github: false, + login_email: true, + signup_sso_google: false, + signup_sso_github: false, + signup_email: true, application: fixtures.application }) }) @@ -262,10 +300,34 @@ describe('AnalyticsTrackerAdapter', () => { it('should track the user authorized sso event with the correct parameters', () => { const { sut, analyticsClientSpy } = makeSut() - sut.signUp.userAuthorizedSso({ method: 'google' }).track() + const signupTypeFlags = { + login_sso_google: true, + login_sso_github: false, + login_email: false, + signup_sso_google: true, + signup_sso_github: false, + signup_email: false + } + + sut.signUp + .userAuthorizedSso({ + method: 'google', + firstSessionUrl: 'https://example.com/signup', + signupType: 'sso', + signupTypeFlags + }) + .track() expect(analyticsClientSpy.track).toHaveBeenCalledWith('User Authorized SSO', { method: 'google', + first_session_url: 'https://example.com/signup', + signup_type: 'signup_sso_google', + login_sso_google: true, + login_sso_github: false, + login_email: false, + signup_sso_google: true, + signup_sso_github: false, + signup_email: false, application: fixtures.application }) }) diff --git a/src/tests/plugins/analytics/trackers/SignInTracker.test.js b/src/tests/plugins/analytics/trackers/SignInTracker.test.js new file mode 100644 index 0000000000..57435eaa51 --- /dev/null +++ b/src/tests/plugins/analytics/trackers/SignInTracker.test.js @@ -0,0 +1,292 @@ +import { SignInTracker } from '@/plugins/analytics/trackers/SignInTracker' +import { describe, expect, it, vi, beforeEach } from 'vitest' + +vi.mock('@/services/hubspot-services', () => ({ + hubspotFormSubmitService: vi.fn() +})) + +const makeSut = () => { + const trackerAdapterSpy = { + addEvent: vi.fn().mockReturnThis() + } + const sut = new SignInTracker(trackerAdapterSpy) + + return { + sut, + trackerAdapterSpy + } +} + +describe('SignInTracker', () => { + let hubspotFormSubmitService + + beforeEach(async () => { + vi.clearAllMocks() + hubspotFormSubmitService = (await import('@/services/hubspot-services')) + .hubspotFormSubmitService + }) + + describe('userSignedIn', () => { + it('should add Segment event with correct event name and props', () => { + const { sut, trackerAdapterSpy } = makeSut() + + sut.userSignedIn({ + method: 'email', + signupTypeFlags: { login_email: true } + }) + + expect(trackerAdapterSpy.addEvent).toHaveBeenCalledWith({ + eventName: 'User Signed In', + props: { + method: 'email', + login_sso_google: false, + login_sso_github: false, + login_email: true, + signup_sso_google: false, + signup_sso_github: false, + signup_email: false + } + }) + }) + + it('should call HubSpot form submission when email and userId are provided', async () => { + const { sut } = makeSut() + + sut.userSignedIn({ + method: 'email', + signupTypeFlags: { login_email: true }, + email: 'test@example.com', + userId: 'user-123' + }) + + expect(hubspotFormSubmitService).toHaveBeenCalledWith({ + email: 'test@example.com', + form_action: 'login_email', + user_id__rtm_: 'user-123', + segment_userid: 'user-123', + firstname: undefined, + lastname: undefined, + mobilephone: undefined, + company: undefined, + github_handle: undefined + }) + }) + + it('should not call HubSpot when email is missing', () => { + const { sut } = makeSut() + + sut.userSignedIn({ + method: 'email', + userId: 'user-123', + signupTypeFlags: { login_email: true } + }) + + expect(hubspotFormSubmitService).not.toHaveBeenCalled() + }) + + it('should not call HubSpot when userId is missing', () => { + const { sut } = makeSut() + + sut.userSignedIn({ + method: 'email', + email: 'test@example.com', + signupTypeFlags: { login_email: true } + }) + + expect(hubspotFormSubmitService).not.toHaveBeenCalled() + }) + + it('should ensure user_id__rtm_ equals segment_userid', async () => { + const { sut } = makeSut() + + sut.userSignedIn({ + method: 'google', + signupTypeFlags: { login_sso_google: true }, + email: 'test@example.com', + userId: 'console-user-456' + }) + + expect(hubspotFormSubmitService).toHaveBeenCalledWith( + expect.objectContaining({ + user_id__rtm_: 'console-user-456', + segment_userid: 'console-user-456' + }) + ) + }) + + it('should pass all optional fields to HubSpot when provided', async () => { + const { sut } = makeSut() + + sut.userSignedIn({ + method: 'github', + signupTypeFlags: { login_sso_github: true }, + email: 'test@example.com', + userId: 'user-789', + firstname: 'John', + lastname: 'Doe', + phone: '+1234567890', + company: 'Acme Inc', + githubHandle: 'johndoe' + }) + + expect(hubspotFormSubmitService).toHaveBeenCalledWith({ + email: 'test@example.com', + form_action: 'login_sso_github', + user_id__rtm_: 'user-789', + segment_userid: 'user-789', + firstname: 'John', + lastname: 'Doe', + mobilephone: '+1234567890', + company: 'Acme Inc', + github_handle: 'johndoe' + }) + }) + + it('should use correct form_action for login_sso_google', async () => { + const { sut } = makeSut() + + sut.userSignedIn({ + method: 'google', + signupTypeFlags: { login_sso_google: true }, + email: 'test@example.com', + userId: 'user-123' + }) + + expect(hubspotFormSubmitService).toHaveBeenCalledWith( + expect.objectContaining({ + form_action: 'login_sso_google' + }) + ) + }) + + it('should use correct form_action for login_sso_github', async () => { + const { sut } = makeSut() + + sut.userSignedIn({ + method: 'github', + signupTypeFlags: { login_sso_github: true }, + email: 'test@example.com', + userId: 'user-123' + }) + + expect(hubspotFormSubmitService).toHaveBeenCalledWith( + expect.objectContaining({ + form_action: 'login_sso_github' + }) + ) + }) + + it('should use correct form_action for login_email', async () => { + const { sut } = makeSut() + + sut.userSignedIn({ + method: 'email', + signupTypeFlags: { login_email: true }, + email: 'test@example.com', + userId: 'user-123' + }) + + expect(hubspotFormSubmitService).toHaveBeenCalledWith( + expect.objectContaining({ + form_action: 'login_email' + }) + ) + }) + + it('should default to login_email when no signupTypeFlags provided', async () => { + const { sut } = makeSut() + + sut.userSignedIn({ + method: 'email', + email: 'test@example.com', + userId: 'user-123' + }) + + expect(hubspotFormSubmitService).toHaveBeenCalledWith( + expect.objectContaining({ + form_action: 'login_email' + }) + ) + }) + + it('should return trackerAdapter for chaining', () => { + const { sut, trackerAdapterSpy } = makeSut() + + const result = sut.userSignedIn({ + method: 'email', + signupTypeFlags: { login_email: true } + }) + + expect(result).toBe(trackerAdapterSpy) + }) + }) + + describe('userClickedSignIn', () => { + it('should add Segment event with correct event name', () => { + const { sut, trackerAdapterSpy } = makeSut() + + sut.userClickedSignIn({ method: 'email' }) + + expect(trackerAdapterSpy.addEvent).toHaveBeenCalledWith({ + eventName: 'User Clicked to Sign In', + props: { + method: 'email' + } + }) + }) + + it('should return trackerAdapter for chaining', () => { + const { sut, trackerAdapterSpy } = makeSut() + + const result = sut.userClickedSignIn({ method: 'google' }) + + expect(result).toBe(trackerAdapterSpy) + }) + }) + + describe('userFailedSignIn', () => { + it('should add Segment event with error details', () => { + const { sut, trackerAdapterSpy } = makeSut() + + sut.userFailedSignIn({ + method: 'email', + signupTypeFlags: { login_email: true } + }) + + expect(trackerAdapterSpy.addEvent).toHaveBeenCalledWith({ + eventName: 'User Failed to Sign In', + props: { + method: 'email', + login_sso_google: false, + login_sso_github: false, + login_email: true, + signup_sso_google: false, + signup_sso_github: false, + signup_email: false + } + }) + }) + + it('should not call HubSpot', () => { + const { sut } = makeSut() + + sut.userFailedSignIn({ + method: 'email', + signupTypeFlags: { login_email: true } + }) + + expect(hubspotFormSubmitService).not.toHaveBeenCalled() + }) + + it('should return trackerAdapter for chaining', () => { + const { sut, trackerAdapterSpy } = makeSut() + + const result = sut.userFailedSignIn({ + method: 'email', + signupTypeFlags: { login_email: true } + }) + + expect(result).toBe(trackerAdapterSpy) + }) + }) +}) diff --git a/src/tests/plugins/analytics/trackers/SignUpTracker.test.js b/src/tests/plugins/analytics/trackers/SignUpTracker.test.js new file mode 100644 index 0000000000..62bc0607f2 --- /dev/null +++ b/src/tests/plugins/analytics/trackers/SignUpTracker.test.js @@ -0,0 +1,471 @@ +import { SignUpTracker } from '@/plugins/analytics/trackers/SignUpTracker' +import { describe, expect, it, vi, beforeEach } from 'vitest' + +vi.mock('@/services/hubspot-services', () => ({ + hubspotFormSubmitService: vi.fn() +})) + +const makeSut = () => { + const trackerAdapterSpy = { + addEvent: vi.fn().mockReturnThis() + } + const sut = new SignUpTracker(trackerAdapterSpy) + + return { + sut, + trackerAdapterSpy + } +} + +describe('SignUpTracker', () => { + let hubspotFormSubmitService + + beforeEach(async () => { + vi.clearAllMocks() + hubspotFormSubmitService = (await import('@/services/hubspot-services')) + .hubspotFormSubmitService + }) + + describe('userSignedUp', () => { + it('should add Segment event with correct event name and props', () => { + const { sut, trackerAdapterSpy } = makeSut() + + sut.userSignedUp({ + method: 'email', + firstSessionUrl: 'https://console.azion.com/signup?ref=docs', + signupTypeFlags: { signup_email: true } + }) + + expect(trackerAdapterSpy.addEvent).toHaveBeenCalledWith({ + eventName: 'User Signed Up', + props: { + method: 'email', + first_session_url: 'https://console.azion.com/signup?ref=docs', + signup_type: 'signup_email', + login_sso_google: false, + login_sso_github: false, + login_email: false, + signup_sso_google: false, + signup_sso_github: false, + signup_email: true + } + }) + }) + + it('should call HubSpot form submission when email and userId are provided', async () => { + const { sut } = makeSut() + + sut.userSignedUp({ + method: 'email', + firstSessionUrl: 'https://console.azion.com/signup', + signupTypeFlags: { signup_email: true }, + email: 'test@example.com', + userId: 'user-123' + }) + + expect(hubspotFormSubmitService).toHaveBeenCalledWith({ + email: 'test@example.com', + form_action: 'signup_email', + user_id__rtm_: 'user-123', + segment_userid: 'user-123', + firstname: undefined, + lastname: undefined, + mobilephone: undefined, + company: undefined, + github_handle: undefined + }) + }) + + it('should not call HubSpot when email is missing', () => { + const { sut } = makeSut() + + sut.userSignedUp({ + method: 'email', + userId: 'user-123', + signupTypeFlags: { signup_email: true } + }) + + expect(hubspotFormSubmitService).not.toHaveBeenCalled() + }) + + it('should not call HubSpot when userId is missing', () => { + const { sut } = makeSut() + + sut.userSignedUp({ + method: 'email', + email: 'test@example.com', + signupTypeFlags: { signup_email: true } + }) + + expect(hubspotFormSubmitService).not.toHaveBeenCalled() + }) + + it('should ensure user_id__rtm_ equals segment_userid', async () => { + const { sut } = makeSut() + + sut.userSignedUp({ + method: 'google', + firstSessionUrl: 'https://console.azion.com/signup', + signupTypeFlags: { signup_sso_google: true }, + email: 'test@example.com', + userId: 'console-user-456' + }) + + expect(hubspotFormSubmitService).toHaveBeenCalledWith( + expect.objectContaining({ + user_id__rtm_: 'console-user-456', + segment_userid: 'console-user-456' + }) + ) + }) + + it('should pass all optional fields to HubSpot when provided', async () => { + const { sut } = makeSut() + + sut.userSignedUp({ + method: 'github', + firstSessionUrl: 'https://console.azion.com/signup', + signupTypeFlags: { signup_sso_github: true }, + email: 'test@example.com', + userId: 'user-789', + firstname: 'John', + lastname: 'Doe', + phone: '+1234567890', + company: 'Acme Inc', + githubHandle: 'johndoe' + }) + + expect(hubspotFormSubmitService).toHaveBeenCalledWith({ + email: 'test@example.com', + form_action: 'signup_sso_github', + user_id__rtm_: 'user-789', + segment_userid: 'user-789', + firstname: 'John', + lastname: 'Doe', + mobilephone: '+1234567890', + company: 'Acme Inc', + github_handle: 'johndoe' + }) + }) + + it('should use correct form_action for signup_sso_google', async () => { + const { sut } = makeSut() + + sut.userSignedUp({ + method: 'google', + signupTypeFlags: { signup_sso_google: true }, + email: 'test@example.com', + userId: 'user-123' + }) + + expect(hubspotFormSubmitService).toHaveBeenCalledWith( + expect.objectContaining({ + form_action: 'signup_sso_google' + }) + ) + }) + + it('should use correct form_action for signup_sso_github', async () => { + const { sut } = makeSut() + + sut.userSignedUp({ + method: 'github', + signupTypeFlags: { signup_sso_github: true }, + email: 'test@example.com', + userId: 'user-123' + }) + + expect(hubspotFormSubmitService).toHaveBeenCalledWith( + expect.objectContaining({ + form_action: 'signup_sso_github' + }) + ) + }) + + it('should use correct form_action for login_email', async () => { + const { sut } = makeSut() + + sut.userSignedUp({ + method: 'email', + signupTypeFlags: { login_email: true }, + email: 'test@example.com', + userId: 'user-123' + }) + + expect(hubspotFormSubmitService).toHaveBeenCalledWith( + expect.objectContaining({ + form_action: 'login_email' + }) + ) + }) + + it('should use correct form_action for login_sso_google', async () => { + const { sut } = makeSut() + + sut.userSignedUp({ + method: 'google', + signupTypeFlags: { login_sso_google: true }, + email: 'test@example.com', + userId: 'user-123' + }) + + expect(hubspotFormSubmitService).toHaveBeenCalledWith( + expect.objectContaining({ + form_action: 'login_sso_google' + }) + ) + }) + + it('should use correct form_action for login_sso_github', async () => { + const { sut } = makeSut() + + sut.userSignedUp({ + method: 'github', + signupTypeFlags: { login_sso_github: true }, + email: 'test@example.com', + userId: 'user-123' + }) + + expect(hubspotFormSubmitService).toHaveBeenCalledWith( + expect.objectContaining({ + form_action: 'login_sso_github' + }) + ) + }) + + it('should default to signup_email when no signupTypeFlags provided', async () => { + const { sut } = makeSut() + + sut.userSignedUp({ + method: 'email', + email: 'test@example.com', + userId: 'user-123' + }) + + expect(hubspotFormSubmitService).toHaveBeenCalledWith( + expect.objectContaining({ + form_action: 'signup_email' + }) + ) + }) + + it('should return trackerAdapter for chaining', () => { + const { sut, trackerAdapterSpy } = makeSut() + + const result = sut.userSignedUp({ + method: 'email', + signupTypeFlags: { signup_email: true } + }) + + expect(result).toBe(trackerAdapterSpy) + }) + }) + + describe('userClickedSignedUp', () => { + it('should add Segment event with correct event name', () => { + const { sut, trackerAdapterSpy } = makeSut() + + sut.userClickedSignedUp({ method: 'email' }) + + expect(trackerAdapterSpy.addEvent).toHaveBeenCalledWith({ + eventName: 'User Clicked to Sign Up', + props: { + method: 'email' + } + }) + }) + + it('should return trackerAdapter for chaining', () => { + const { sut, trackerAdapterSpy } = makeSut() + + const result = sut.userClickedSignedUp({ method: 'google' }) + + expect(result).toBe(trackerAdapterSpy) + }) + }) + + describe('userAuthorizedSso', () => { + it('should add Segment event with correct event name and props', () => { + const { sut, trackerAdapterSpy } = makeSut() + + sut.userAuthorizedSso({ + method: 'google', + firstSessionUrl: 'https://console.azion.com/signup', + signupTypeFlags: { signup_sso_google: true } + }) + + expect(trackerAdapterSpy.addEvent).toHaveBeenCalledWith({ + eventName: 'User Authorized SSO', + props: { + method: 'google', + first_session_url: 'https://console.azion.com/signup', + signup_type: 'signup_sso_google', + login_sso_google: false, + login_sso_github: false, + login_email: false, + signup_sso_google: true, + signup_sso_github: false, + signup_email: false + } + }) + }) + + it('should not call HubSpot (only Segment event)', () => { + const { sut } = makeSut() + + sut.userAuthorizedSso({ + method: 'github', + signupTypeFlags: { signup_sso_github: true }, + email: 'test@example.com', + userId: 'user-123' + }) + + // HubSpot should only be called by userSignedUp, not userAuthorizedSso + expect(hubspotFormSubmitService).not.toHaveBeenCalled() + }) + + it('should return trackerAdapter for chaining', () => { + const { sut, trackerAdapterSpy } = makeSut() + + const result = sut.userAuthorizedSso({ method: 'github' }) + + expect(result).toBe(trackerAdapterSpy) + }) + }) + + describe('userFailedSignUp', () => { + it('should add Segment event with error details', () => { + const { sut, trackerAdapterSpy } = makeSut() + + sut.userFailedSignUp({ + errorType: 'api', + fieldName: 'email', + errorMessage: 'Invalid email format' + }) + + expect(trackerAdapterSpy.addEvent).toHaveBeenCalledWith({ + eventName: 'User Failed to Sign Up', + props: { + errorType: 'api', + fieldName: 'email', + errorMessage: 'Invalid email format' + } + }) + }) + + it('should not call HubSpot', () => { + const { sut } = makeSut() + + sut.userFailedSignUp({ + errorType: 'field', + fieldName: 'password', + errorMessage: 'Password too short' + }) + + expect(hubspotFormSubmitService).not.toHaveBeenCalled() + }) + + it('should return trackerAdapter for chaining', () => { + const { sut, trackerAdapterSpy } = makeSut() + + const result = sut.userFailedSignUp({ + errorType: 'api', + fieldName: 'email', + errorMessage: 'Error' + }) + + expect(result).toBe(trackerAdapterSpy) + }) + }) + + describe('submittedAdditionalData', () => { + it('should add Segment event with additional data', () => { + const { sut, trackerAdapterSpy } = makeSut() + + sut.submittedAdditionalData({ + use: 'Personal projects', + role: 'Developer', + inputRole: 'Senior Dev', + companySize: '1-10', + onboardingSession: 'scheduled', + companyWebsite: 'https://example.com', + fullName: 'John Doe' + }) + + expect(trackerAdapterSpy.addEvent).toHaveBeenCalledWith({ + eventName: 'Submitted Additional Data', + props: { + use: 'Personal projects', + role: 'Developer', + inputRole: 'Senior Dev', + companySize: '1-10', + onboardingSchedule: 'scheduled', + website: 'https://example.com', + name: 'John Doe' + } + }) + }) + + it('should return trackerAdapter for chaining', () => { + const { sut, trackerAdapterSpy } = makeSut() + + const result = sut.submittedAdditionalData({}) + + expect(result).toBe(trackerAdapterSpy) + }) + }) + + describe('failedSubmitAdditionalData', () => { + it('should add Segment event with error details', () => { + const { sut, trackerAdapterSpy } = makeSut() + + sut.failedSubmitAdditionalData({ + errorType: 'validation', + errorMessage: 'Field required', + fieldName: 'company' + }) + + expect(trackerAdapterSpy.addEvent).toHaveBeenCalledWith({ + eventName: 'Failed to Submit Additional Data', + props: { + errorType: 'validation', + errorMessage: 'Field required', + fieldName: 'company' + } + }) + }) + + it('should return trackerAdapter for chaining', () => { + const { sut, trackerAdapterSpy } = makeSut() + + const result = sut.failedSubmitAdditionalData({ + errorType: 'api', + errorMessage: 'Error', + fieldName: 'role' + }) + + expect(result).toBe(trackerAdapterSpy) + }) + }) + + describe('userActivatedAccount', () => { + it('should add Segment event', () => { + const { sut, trackerAdapterSpy } = makeSut() + + sut.userActivatedAccount() + + expect(trackerAdapterSpy.addEvent).toHaveBeenCalledWith({ + eventName: 'User Activated Account', + props: {} + }) + }) + + it('should return trackerAdapter for chaining', () => { + const { sut, trackerAdapterSpy } = makeSut() + + const result = sut.userActivatedAccount() + + expect(result).toBe(trackerAdapterSpy) + }) + }) +}) diff --git a/src/tests/services/hubspot-services/hubspot-form-submit-service.test.js b/src/tests/services/hubspot-services/hubspot-form-submit-service.test.js new file mode 100644 index 0000000000..d290bfa39c --- /dev/null +++ b/src/tests/services/hubspot-services/hubspot-form-submit-service.test.js @@ -0,0 +1,124 @@ +import { AxiosHttpClientAdapter } from '@/services/axios/AxiosHttpClientAdapter' +import { hubspotFormSubmitService } from '@/services/hubspot-services/hubspot-form-submit-service' +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@/services/axios/AxiosHttpClientAdapter') + +describe('hubspotFormSubmitService', () => { + it('should submit form with mandatory fields only', async () => { + const requestSpy = vi.spyOn(AxiosHttpClientAdapter, 'request').mockResolvedValueOnce({ + statusCode: 200 + }) + + await hubspotFormSubmitService({ + email: 'test@example.com', + form_action: 'signup_email', + user_id__rtm_: 'user-123', + segment_userid: 'user-123' + }) + + expect(requestSpy).toHaveBeenCalledWith({ + url: 'https://api.hsforms.com/submissions/v3/integration/submit/145499104/b37e4a6a-8808-4e53-862a-7f82728138b9', + method: 'POST', + body: { + fields: [ + { name: 'email', value: 'test@example.com' }, + { name: 'form_action', value: 'signup_email' }, + { name: 'user_id__rtm_', value: 'user-123' }, + { name: 'segment_userid', value: 'user-123' } + ] + }, + headers: { + 'Content-Type': 'application/json' + } + }) + }) + + it('should submit form with all optional fields', async () => { + const requestSpy = vi.spyOn(AxiosHttpClientAdapter, 'request').mockResolvedValueOnce({ + statusCode: 200 + }) + + await hubspotFormSubmitService({ + email: 'test@example.com', + form_action: 'signup_sso_google', + user_id__rtm_: 'user-456', + segment_userid: 'user-456', + firstname: 'John', + lastname: 'Doe', + mobilephone: '+1234567890', + company: 'Acme Inc', + github_handle: 'johndoe' + }) + + expect(requestSpy).toHaveBeenCalledWith({ + url: 'https://api.hsforms.com/submissions/v3/integration/submit/145499104/b37e4a6a-8808-4e53-862a-7f82728138b9', + method: 'POST', + body: { + fields: [ + { name: 'email', value: 'test@example.com' }, + { name: 'form_action', value: 'signup_sso_google' }, + { name: 'user_id__rtm_', value: 'user-456' }, + { name: 'segment_userid', value: 'user-456' }, + { name: 'firstname', value: 'John' }, + { name: 'lastname', value: 'Doe' }, + { name: 'mobilephone', value: '+1234567890' }, + { name: 'company', value: 'Acme Inc' }, + { name: 'github_handle', value: 'johndoe' } + ] + }, + headers: { + 'Content-Type': 'application/json' + } + }) + }) + + it('should handle errors gracefully without throwing', async () => { + vi.spyOn(AxiosHttpClientAdapter, 'request').mockRejectedValueOnce(new Error('Network error')) + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + // Should not throw + const result = await hubspotFormSubmitService({ + email: 'test@example.com', + form_action: 'signup_email', + user_id__rtm_: 'user-123', + segment_userid: 'user-123' + }) + + expect(result).toEqual({ success: false, error: 'Network error' }) + + expect(consoleWarnSpy).toHaveBeenCalledWith('HubSpot form submission failed:', { + error: 'Network error', + email: 'test@example.com', + form_action: 'signup_email' + }) + }) + + it('should use correct form_action values for different signup types', async () => { + const requestSpy = vi.spyOn(AxiosHttpClientAdapter, 'request').mockResolvedValue({ + statusCode: 200 + }) + + const formActions = [ + 'signup_email', + 'signup_sso_google', + 'signup_sso_github', + 'login_email', + 'login_sso_google', + 'login_sso_github' + ] + + for (const formAction of formActions) { + await hubspotFormSubmitService({ + email: 'test@example.com', + form_action: formAction, + user_id__rtm_: 'user-123', + segment_userid: 'user-123' + }) + + const lastCall = requestSpy.mock.calls[requestSpy.mock.calls.length - 1] + const formActionField = lastCall[0].body.fields.find((field) => field.name === 'form_action') + expect(formActionField.value).toBe(formAction) + } + }) +}) diff --git a/src/views/Login/LoginView.vue b/src/views/Login/LoginView.vue index c268336a62..c8fac20712 100644 --- a/src/views/Login/LoginView.vue +++ b/src/views/Login/LoginView.vue @@ -67,8 +67,11 @@ const isActivatedEmail = !!email && !activated if (isActivatedEmail) { + // Set signup_email flag for tracking when user completes email signup + const accountStore = useAccountStore() + accountStore.setSignupTypeFlag('signup_email') + tracker.signUp.userActivatedAccount().track() - tracker.signUp.userSignedUp({ method: 'email' }).track() const newQuery = { ...route.query, activated: 'true' } router.replace({ query: newQuery }) diff --git a/yarn.lock b/yarn.lock index 5166badad7..0609199d8a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3713,10 +3713,9 @@ autoprefixer@^10.4.14: resolved "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.22.tgz" integrity sha512-ARe0v/t9gO28Bznv6GgqARmVqcWOV3mfgUPn9becPHMiD3o9BwlRgaeccZnwTpZ7Zwqrm+c1sUSsMxIzQzc8Xg== dependencies: - browserslist "^4.27.0" - caniuse-lite "^1.0.30001754" + browserslist "^4.28.1" + caniuse-lite "^1.0.30001774" fraction.js "^5.3.4" - normalize-range "^0.1.2" picocolors "^1.1.1" postcss-value-parser "^4.2.0" @@ -4063,7 +4062,7 @@ browserify-zlib@^0.2.0: dependencies: pako "~1.0.5" -browserslist@^4.24.0, browserslist@^4.27.0, browserslist@^4.28.0: +browserslist@^4.24.0, browserslist@^4.28.0: version "4.28.0" resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz" integrity sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ== @@ -4218,6 +4217,11 @@ caniuse-lite@^1.0.30001759: resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz" integrity sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg== +caniuse-lite@^1.0.30001774: + version "1.0.30001787" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz#fd25c5e42e2d35df5c75eddda00d15d9c0c68f81" + integrity sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg== + caseless@~0.12.0: version "0.12.0" resolved "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz"