diff --git a/libs/domains/users-sign-up/feature/src/index.ts b/libs/domains/users-sign-up/feature/src/index.ts index 74051acaeda..106f8c6f461 100644 --- a/libs/domains/users-sign-up/feature/src/index.ts +++ b/libs/domains/users-sign-up/feature/src/index.ts @@ -1,2 +1,3 @@ export * from './lib/hooks/use-user-sign-up/use-user-sign-up' export * from './lib/hooks/use-create-user-sign-up/use-create-user-sign-up' +export * from './lib/hooks/use-signup-hubspot/use-signup-hubspot' diff --git a/libs/domains/users-sign-up/feature/src/lib/hooks/use-create-user-sign-up/use-create-user-sign-up.ts b/libs/domains/users-sign-up/feature/src/lib/hooks/use-create-user-sign-up/use-create-user-sign-up.ts index 2a2e6d677c8..88883a8c9b1 100644 --- a/libs/domains/users-sign-up/feature/src/lib/hooks/use-create-user-sign-up/use-create-user-sign-up.ts +++ b/libs/domains/users-sign-up/feature/src/lib/hooks/use-create-user-sign-up/use-create-user-sign-up.ts @@ -1,22 +1,12 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { mutations } from '@qovery/domains/users-sign-up/data-access' +import { getStoredTrackingParams } from '@qovery/shared/util-hooks' import { queries } from '@qovery/state/util-queries' -import { type CargoSignupPayload, useSignUpCargo } from '../use-signup-cargo/use-signup-cargo' - -function getUtmParams() { - return { - utm_source: localStorage.getItem('utm_source'), - utm_medium: localStorage.getItem('utm_medium'), - utm_campaign: localStorage.getItem('utm_campaign'), - utm_term: localStorage.getItem('utm_term'), - utm_content: localStorage.getItem('utm_content'), - gclid: localStorage.getItem('gclid'), - } -} +import { type HubspotSignupPayload, useSignUpHubspot } from '../use-signup-hubspot/use-signup-hubspot' export function useCreateUserSignUp() { const queryClient = useQueryClient() - const { mutate: signUpCargo } = useSignUpCargo() + const { mutate: signUpHubspot } = useSignUpHubspot() return useMutation(mutations.createUserSignup, { onSuccess(_, variables) { @@ -24,9 +14,9 @@ export function useCreateUserSignUp() { queryKey: queries.usersSignUp.get.queryKey, }) - const utmParams = getUtmParams() + const trackingParams = getStoredTrackingParams() - const cargoPayload: CargoSignupPayload = { + const signupPayload: HubspotSignupPayload = { email: variables.user_email, first_name: variables.first_name, last_name: variables.last_name, @@ -34,9 +24,11 @@ export function useCreateUserSignUp() { job_title: variables.user_role || '', phone: variables.phone ?? '', signup_source: 'Console', - ...utmParams, + qovery_interest: variables.qovery_usage ?? 'Not specified', + which_cloud_service_provider_do_you_use_: variables.infrastructure_hosting ?? 'Not specified', + tracking_params: trackingParams, } - signUpCargo(cargoPayload) + signUpHubspot(signupPayload) }, meta: { notifyOnError: true, diff --git a/libs/domains/users-sign-up/feature/src/lib/hooks/use-signup-cargo/use-signup-cargo.ts b/libs/domains/users-sign-up/feature/src/lib/hooks/use-signup-cargo/use-signup-cargo.ts deleted file mode 100644 index c5b35b6a50a..00000000000 --- a/libs/domains/users-sign-up/feature/src/lib/hooks/use-signup-cargo/use-signup-cargo.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { useMutation } from '@tanstack/react-query' -import { CARGO_API_TOKEN } from '@qovery/shared/util-node-env' - -export interface CargoSignupPayload { - email: string - first_name: string - last_name: string - company: string - job_title: string - signup_source: string - phone: string - utm_source?: string | null - utm_medium?: string | null - utm_campaign?: string | null - utm_term?: string | null - utm_content?: string | null - gclid?: string | null -} - -const CARGO_API_URL = 'https://api.getcargo.io/v1/models/7e42e545-bdee-438b-b2dc-3799e95bf046/records/ingest' - -// https://qovery.slack.com/archives/C08M2RT8T29/p1746543979992499 -export function useSignUpCargo() { - const postToCargo = async (payload: CargoSignupPayload) => { - try { - const response = await fetch(`${CARGO_API_URL}?token=${CARGO_API_TOKEN}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(payload), - }) - - if (!response.ok) { - throw new Error(`Error posting signup data to Cargo: ${response.status} ${response.statusText}`) - } - - return await response.json() - } catch (error) { - console.error('Error posting signup data to Cargo:', error) - throw error - } - } - - return useMutation({ - mutationFn: postToCargo, - meta: { - notifyOnError: true, - }, - }) -} - -export default useSignUpCargo diff --git a/libs/domains/users-sign-up/feature/src/lib/hooks/use-signup-hubspot/use-signup-hubspot.ts b/libs/domains/users-sign-up/feature/src/lib/hooks/use-signup-hubspot/use-signup-hubspot.ts new file mode 100644 index 00000000000..c42ab35ffc6 --- /dev/null +++ b/libs/domains/users-sign-up/feature/src/lib/hooks/use-signup-hubspot/use-signup-hubspot.ts @@ -0,0 +1,75 @@ +import { useMutation } from '@tanstack/react-query' + +const HUBSPOT_PORTAL_ID = '25346960' +const HUBSPOT_FORM_ID = '70df367c-130e-4bf8-866a-c972f565c67a' +const HUBSPOT_SUBMIT_URL = `https://api.hsforms.com/submissions/v3/integration/submit/${HUBSPOT_PORTAL_ID}/${HUBSPOT_FORM_ID}` + +export interface HubspotSignupPayload { + email: string + first_name: string + last_name: string + company: string + job_title: string + signup_source: string + phone: string + /** Maps to HubSpot required field qovery_interest (e.g. from qovery_usage) */ + qovery_interest: string + /** Maps to HubSpot required field which_cloud_service_provider_do_you_use_ */ + which_cloud_service_provider_do_you_use_: string + /** All URL/tracking params captured at landing (utm_*, gclid, etc.) — sent as HubSpot fields */ + tracking_params?: Record +} + +function toHubspotFields(payload: HubspotSignupPayload): { name: string; value: string }[] { + const fields: { name: string; value: string }[] = [ + { name: 'email', value: payload.email }, + { name: 'firstname', value: payload.first_name }, + { name: 'lastname', value: payload.last_name }, + { name: 'company', value: payload.company }, + { name: 'jobtitle', value: payload.job_title }, + { name: 'phone', value: payload.phone || '—' }, + { name: 'signup_source', value: payload.signup_source }, + { name: 'qovery_interest', value: payload.qovery_interest || 'Not specified' }, + { + name: 'which_cloud_service_provider_do_you_use_', + value: payload.which_cloud_service_provider_do_you_use_ || 'Not specified', + }, + ] + const tracking = payload.tracking_params ?? {} + for (const [name, value] of Object.entries(tracking)) { + if (name && value != null && value !== '') { + fields.push({ name, value }) + } + } + return fields +} + +export function useSignUpHubspot() { + const postToHubspot = async (payload: HubspotSignupPayload) => { + const response = await fetch(HUBSPOT_SUBMIT_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + fields: toHubspotFields(payload), + }), + }) + + if (!response.ok) { + const text = await response.text() + throw new Error(`Error posting signup data to HubSpot: ${response.status} ${response.statusText} - ${text}`) + } + + return response.json() + } + + return useMutation({ + mutationFn: postToHubspot, + meta: { + notifyOnError: true, + }, + }) +} + +export default useSignUpHubspot diff --git a/libs/pages/onboarding/src/lib/feature/container/container.tsx b/libs/pages/onboarding/src/lib/feature/container/container.tsx index 68bdd0fb484..aa135101421 100644 --- a/libs/pages/onboarding/src/lib/feature/container/container.tsx +++ b/libs/pages/onboarding/src/lib/feature/container/container.tsx @@ -66,14 +66,6 @@ export function Container(props: PropsWithChildren) { const hasExistingOrganization = organizations.length > 0 const totalSteps = hasExistingOrganization || hasDxAuth ? 2 : ROUTER_ONBOARDING.length - useEffect(() => { - if (hasDxAuth) { - navigate(`${ONBOARDING_URL}${ONBOARDING_PROJECT_URL}`, { - state: location.state, - }) - } - }, [currentPath, hasDxAuth, navigate, location.state]) - const currentStep = hasExistingOrganization ? currentPath === ONBOARDING_PROJECT_URL ? 2 diff --git a/libs/shared/util-hooks/src/lib/use-utm-params/use-utm-params.ts b/libs/shared/util-hooks/src/lib/use-utm-params/use-utm-params.ts index 79b88993f82..eb5ee0540cf 100644 --- a/libs/shared/util-hooks/src/lib/use-utm-params/use-utm-params.ts +++ b/libs/shared/util-hooks/src/lib/use-utm-params/use-utm-params.ts @@ -1,46 +1,56 @@ import { useEffect, useMemo } from 'react' import { useSearchParams } from 'react-router-dom' -export interface UtmParams { - utm_source?: string - utm_medium?: string - utm_campaign?: string - utm_term?: string - utm_content?: string - gclid?: string +export const TRACKING_PARAMS_STORAGE_KEY = 'tracking_params' + +/** Returns all URL search params as a record (dynamic keys only). */ +export function getTrackingParams(searchParams: URLSearchParams): Record { + const result: Record = {} + searchParams.forEach((value, key) => { + if (value) result[key] = value + }) + return result } -const UTM_KEYS: (keyof UtmParams)[] = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'gclid'] - -export function getUtmParams(searchParams: URLSearchParams): UtmParams { - const utmParams: UtmParams = {} - - for (const key of UTM_KEYS) { - const value = searchParams.get(key) - if (value) { - utmParams[key] = value +/** Returns all URL params stored at landing. */ +export function getStoredTrackingParams(): Record { + try { + const raw = localStorage.getItem(TRACKING_PARAMS_STORAGE_KEY) + if (raw) { + const parsed = JSON.parse(raw) as Record + return Object.fromEntries( + Object.entries(parsed).filter( + (entry): entry is [string, string] => + typeof entry[0] === 'string' && typeof entry[1] === 'string' && entry[1].length > 0 + ) + ) } + } catch { + // ignore invalid JSON } - - return utmParams + return {} } -export function useUtmParams(): UtmParams { +export function useTrackingParams(): Record { const [searchParams] = useSearchParams() - - const utmParams = useMemo(() => getUtmParams(searchParams), [searchParams]) - - return utmParams + return useMemo(() => getTrackingParams(searchParams), [searchParams]) } +// Auth0 OAuth callback params — excluded from tracking storage and from payloads sent to HubSpot +const OAUTH_PARAMS = ['code', 'state'] + +/** Persists URL tracking params to localStorage. Merges with existing so Auth0 redirect (?code=&state=) does not overwrite UTMs. Excludes Auth0 code/state from storage. */ export function useCaptureUtmParams() { useEffect(() => { const params = new URLSearchParams(window.location.search) - UTM_KEYS.forEach((key) => { - const value = params.get(key) - if (value) { - localStorage.setItem(key, value) - } + const fromUrl: Record = {} + params.forEach((value, key) => { + if (value && !OAUTH_PARAMS.includes(key)) fromUrl[key] = value }) + const existing = getStoredTrackingParams() + const merged = { ...existing, ...fromUrl } + if (Object.keys(merged).length > 0) { + localStorage.setItem(TRACKING_PARAMS_STORAGE_KEY, JSON.stringify(merged)) + } }, []) }