diff --git a/.cspell.json b/.cspell.json index 8262ddcf8e..6ad454ad71 100644 --- a/.cspell.json +++ b/.cspell.json @@ -605,6 +605,10 @@ "Pyroscope", "PYROSCOPE", "usecases", + "hbspt", + "prepopulating", + "Vonage", + "fieldtype", "usecase", "zulip", "uuidv", diff --git a/apps/api/src/app/auth/dtos/user-registration.dto.ts b/apps/api/src/app/auth/dtos/user-registration.dto.ts index 01fdbe93f1..03e7199541 100644 --- a/apps/api/src/app/auth/dtos/user-registration.dto.ts +++ b/apps/api/src/app/auth/dtos/user-registration.dto.ts @@ -1,11 +1,5 @@ import { IsDefined, IsEmail, IsOptional, MinLength, Matches, MaxLength, IsString, IsEnum } from 'class-validator'; -import { - JobTitleEnum, - passwordConstraints, - ProductUseCases, - ProductUseCasesEnum, - SignUpOriginEnum, -} from '@novu/shared'; +import { JobTitleEnum, passwordConstraints, ProductUseCases, SignUpOriginEnum } from '@novu/shared'; export class UserRegistrationBodyDto { @IsDefined() @IsEmail() diff --git a/apps/api/src/app/organization/dtos/create-organization.dto.ts b/apps/api/src/app/organization/dtos/create-organization.dto.ts index 8a11687f97..ef7a6ab559 100644 --- a/apps/api/src/app/organization/dtos/create-organization.dto.ts +++ b/apps/api/src/app/organization/dtos/create-organization.dto.ts @@ -1,5 +1,5 @@ import { IsDefined, IsEnum, IsOptional, IsString } from 'class-validator'; -import { ICreateOrganizationDto, JobTitleEnum, ProductUseCases, ProductUseCasesEnum } from '@novu/shared'; +import { ICreateOrganizationDto, JobTitleEnum, ProductUseCases } from '@novu/shared'; export class CreateOrganizationDto implements ICreateOrganizationDto { @IsString() diff --git a/apps/web/src/constants/hubspotForms.ts b/apps/web/src/constants/hubspotForms.ts new file mode 100644 index 0000000000..e1cfcecc84 --- /dev/null +++ b/apps/web/src/constants/hubspotForms.ts @@ -0,0 +1,3 @@ +export const HUBSPOT_FORM_IDS = { + SIGN_UP: 'c551a45f-a9fe-47eb-a2e5-4e8540d27695', +}; diff --git a/apps/web/src/pages/auth/QuestionnairePage.tsx b/apps/web/src/pages/auth/QuestionnairePage.tsx index acbbd4c242..2afcf1ab85 100644 --- a/apps/web/src/pages/auth/QuestionnairePage.tsx +++ b/apps/web/src/pages/auth/QuestionnairePage.tsx @@ -3,9 +3,12 @@ import AuthContainer from '../../components/layout/components/AuthContainer'; import { QuestionnaireForm } from './components/QuestionnaireForm'; import { useVercelIntegration } from '../../hooks'; import SetupLoader from './components/SetupLoader'; +import { ENV, IS_DOCKER_HOSTED } from '@novu/shared-web'; +import { HubspotSignupForm } from './components/HubspotSignupForm'; export default function QuestionnairePage() { const { isLoading } = useVercelIntegration(); + const isNovuProd = !IS_DOCKER_HOSTED && ENV === 'production'; return ( @@ -14,9 +17,9 @@ export default function QuestionnairePage() { ) : ( - + {!isNovuProd ? : } )} diff --git a/apps/web/src/pages/auth/components/HubspotSignupForm.tsx b/apps/web/src/pages/auth/components/HubspotSignupForm.tsx new file mode 100644 index 0000000000..fb9278f4b6 --- /dev/null +++ b/apps/web/src/pages/auth/components/HubspotSignupForm.tsx @@ -0,0 +1,140 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useMutation } from '@tanstack/react-query'; +import decode from 'jwt-decode'; +import { useMantineColorScheme } from '@mantine/core'; + +import { JobTitleEnum } from '@novu/shared'; +import type { ProductUseCases, IResponseError, ICreateOrganizationDto, IJwtPayload } from '@novu/shared'; +import { HubspotForm, useSegment } from '@novu/shared-web'; + +import { api } from '../../../api/api.client'; +import { useAuthContext } from '../../../components/providers/AuthProvider'; +import { useVercelIntegration, useVercelParams } from '../../../hooks'; +import { ROUTES } from '../../../constants/routes.enum'; +import { HUBSPOT_FORM_IDS } from '../../../constants/hubspotForms'; +import SetupLoader from './SetupLoader'; + +export function HubspotSignupForm() { + const [loading, setLoading] = useState(); + const navigate = useNavigate(); + const { setToken, token, currentUser } = useAuthContext(); + const { startVercelSetup } = useVercelIntegration(); + const { isFromVercel } = useVercelParams(); + const { colorScheme } = useMantineColorScheme(); + + const segment = useSegment(); + + const { mutateAsync: createOrganizationMutation } = useMutation< + { _id: string }, + IResponseError, + ICreateOrganizationDto + >((data: ICreateOrganizationDto) => api.post(`/v1/organizations`, data)); + + useEffect(() => { + if (token) { + const userData = decode(token); + + if (userData.environmentId) { + if (isFromVercel) { + startVercelSetup(); + + return; + } + + navigate(ROUTES.HOME); + } + } + }, [token, navigate, isFromVercel, startVercelSetup]); + + async function createOrganization(data: IOrganizationCreateForm) { + const { organizationName, jobTitle, ...rest } = data; + const createDto: ICreateOrganizationDto = { ...rest, name: organizationName, jobTitle }; + const organization = await createOrganizationMutation(createDto); + const organizationResponseToken = await api.post(`/v1/auth/organizations/${organization._id}/switch`, {}); + + setToken(organizationResponseToken); + } + + function jwtHasKey(key: string) { + if (!token) return false; + const jwt = decode(token); + + return jwt && jwt[key]; + } + + const handleCreateOrganization = async (data: IOrganizationCreateForm) => { + if (!data?.organizationName) return; + + segment.track('Button Clicked - [Signup]', { action: 'hubspot questionnaire form submit' }); + + setLoading(true); + + if (!jwtHasKey('organizationId')) { + await createOrganization({ ...data }); + } + + setLoading(false); + if (isFromVercel) { + startVercelSetup(); + + return; + } + + navigate(ROUTES.GET_STARTED); + }; + + if (!currentUser || loading) { + return ; + } else { + return ( + { + const submissionValues = values?.submissionValues as unknown as { + company: string; + role___onboarding: string; + }; + + handleCreateOrganization({ + organizationName: submissionValues?.company, + jobTitle: hubspotRoleToJobTitleMapping[submissionValues?.role___onboarding], + }); + }} + colorScheme={colorScheme} + /> + ); + } +} + +interface IOrganizationCreateForm { + organizationName: string; + jobTitle: JobTitleEnum; + domain?: string; + productUseCases?: ProductUseCases; +} + +const hubspotRoleToJobTitleMapping: Record = { + 'Engineer/developer': JobTitleEnum.ENGINEER, + Product: JobTitleEnum.PRODUCT_MANAGER, + Architect: JobTitleEnum.ARCHITECT, + 'Engineering Manager': JobTitleEnum.ENGINEERING_MANAGER, + Designer: JobTitleEnum.DESIGNER, + 'CxO/Founder': JobTitleEnum.FOUNDER, + Marketing: JobTitleEnum.MARKETING_MANAGER, + 'Other (specify)': JobTitleEnum.OTHER, +}; diff --git a/libs/shared-web/package.json b/libs/shared-web/package.json index f373d2dbc7..96e63f89c3 100644 --- a/libs/shared-web/package.json +++ b/libs/shared-web/package.json @@ -31,6 +31,7 @@ "@mantine/hooks": "^5.7.1", "@novu/shared": "^0.24.0", "@segment/analytics-next": "1.59.0", + "@emotion/styled": "^11.6.0", "@sentry/react": "^7.40.0", "@tanstack/react-query": "^4.20.4", "axios": "^1.6.0", diff --git a/libs/shared-web/src/components/HubspotForm.tsx b/libs/shared-web/src/components/HubspotForm.tsx new file mode 100644 index 0000000000..0707ec97ed --- /dev/null +++ b/libs/shared-web/src/components/HubspotForm.tsx @@ -0,0 +1,239 @@ +import { useEffect } from 'react'; +import styled from '@emotion/styled'; +import { HUBSPOT_PORTAL_ID } from '../config'; + +// TODO: remove design system colors after fixing circular dependency from ee +const colors = { + white: '#FFFFFF', + black: '#000000', + B80: '#BEBECC', + B40: '#525266', + B20: '#292933', + vertical: `linear-gradient(0deg, #FF512F 0%, #DD2476 100%)`, + horizontal: `linear-gradient(99deg, #DD2476 0% 0%, #FF512F 100% 100%)`, +}; + +declare global { + interface Window { + hbspt: any; + } +} + +/** + * For the full list of available Hubspot Form options: + * + * @see https://developers.hubspot.com/docs/methods/forms/advanced_form_options + */ +export type HubspotFormProps< + TProperties extends Record, + TKey extends keyof TProperties & string = keyof TProperties & string +> = { + /** + * The Hubspot form ID. This can be found in the Hubspot form embed snippet. + */ + formId: string; + /** + * Properties for prepopulating fields. Keys must match the field names created in the Hubspot form. + */ + properties?: TProperties; + /** + * Read-only properties for prepopulating fields. Keys must match the field names created in the Hubspot form. + */ + readonlyProperties?: Array; + /** + * The name of the property to focus when the form is ready. + */ + focussedProperty?: TKey; + /** + * Callback function to be called when the form is submitted. + */ + onFormSubmitted?: ($form?: any, values?: Record) => void; + /** + * colorScheme + */ + colorScheme: 'dark' | 'light'; +}; + +const HUBSPOT_FORMS_URL = 'https://js.hsforms.net/forms/v2.js'; +const HUBSPOT_REGION = 'na1'; + +const cssClass = 'hubspot-form-wrapper'; +const StyledHubspotForm = styled.div<{ isDark: boolean }>` + .${cssClass} { + color: ${({ isDark }) => (isDark ? colors.B80 : colors.B40)}; + display: flex; + flex-direction: column; + gap: 16px; + + /** Column layout */ + .form-columns-1, + .form-columns-2 { + min-width: 100%; + display: flex; + gap: 20px; + + > * { + width: 100%; + } + + .hs-input { + width: 100%; + } + } + + /** Hyperlinks */ + a { + background-image: ${colors.horizontal}; + background-clip: text; + -webkit-text-fill-color: transparent; + text-decoration: none; + } + + .input { + input, + textarea, + select { + &:focus-visible { + outline: none; + border-color: ${({ isDark }) => (isDark ? colors.white : colors.black)}; + } + } + } + + /** Form fields */ + .hs-input { + appearance: none; + background-color: transparent; + border-radius: 7px; + border: 1px solid ${({ isDark }) => (isDark ? colors.B20 : colors.B80)}; + box-sizing: border-box; + display: block; + font-family: Lato, sans serif; + font-size: 14px; + height: 42px; + line-height: 40px; + margin: 5px 0px; /* Adjusted margin */ + min-height: 50px; + padding-left: 14px; + padding-right: 14px; + resize: none; + text-align: left; + transition: border-color 100ms ease; + width: 100%; + } + + /** Form text area */ + .hs-fieldtype-textarea { + resize: vertical; + min-height: 100px; + } + + /** Form button */ + .hs-button { + appearance: none; + background-color: transparent; + background-image: ${colors.horizontal}; + border-radius: 7px; + border: 0; + box-sizing: border-box; + cursor: pointer; + display: inline-block; + font-family: Lato, sans serif; + font-size: 14px; + font-weight: 600; + height: 42px; + line-height: 1; + padding-left: 22px; + padding-right: 22px; + position: relative; + color: #fff; + text-align: right; + text-decoration: none; + user-select: none; + width: auto; + } + + /** Form field label */ + .hs-form-field label { + cursor: default; + display: inline-block; + font-size: 14px; + font-weight: 700; + line-height: 17px; + margin: 5px 0px; + word-break: break-word; + } + + /** Form Submit action alignment */ + .hs-submit .actions { + display: flex; + justify-content: flex-end; + } + + /** Legal consent container */ + .legal-consent-container { + font-size: 12px; + color: ${colors.B40}; + line-height: 16px; + + .p { + margin-top: 0; + margin-bottom: 0; + } + } + } +`; + +export const HubspotForm = >(props: HubspotFormProps) => { + const elementId = `hubspotForm-${props.formId}`; + + const createForm = () => { + if (window.hbspt) { + window.hbspt.forms.create({ + target: `#${elementId}`, + portalId: HUBSPOT_PORTAL_ID, + region: HUBSPOT_REGION, + cssClass, + onFormReady: (form) => { + if (props.focussedProperty) { + const selector = CSS.escape(`${props.focussedProperty}-${props.formId}`); + const input = form.querySelector(`#${selector}`) as HTMLInputElement; + if (input) { + input.focus(); + } + } + if (props.readonlyProperties) { + props.readonlyProperties.forEach((property) => { + const selector = CSS.escape(`${property}-${props.formId}`); + const input = form.querySelector(`#${selector}`) as HTMLInputElement; + if (input) { + input.setAttribute('readonly', 'true'); + } + }); + } + }, + ...props, + }); + } + }; + + useEffect(() => { + if (!window.hbspt) { + const script = document.createElement('script'); + script.src = HUBSPOT_FORMS_URL; + document.body.appendChild(script); + + script.addEventListener('load', () => { + createForm(); + }); + } else { + createForm(); + } + }, [props]); + + return ( + +
+
+ ); +}; diff --git a/libs/shared-web/src/components/index.ts b/libs/shared-web/src/components/index.ts new file mode 100644 index 0000000000..b88b589cb4 --- /dev/null +++ b/libs/shared-web/src/components/index.ts @@ -0,0 +1 @@ +export * from './HubspotForm'; diff --git a/libs/shared-web/src/config.ts b/libs/shared-web/src/config.ts index f7a9691463..a8b77f3875 100644 --- a/libs/shared-web/src/config.ts +++ b/libs/shared-web/src/config.ts @@ -65,3 +65,5 @@ export const FEATURE_FLAGS = Object.values(FeatureFlagsKeysEnum).reduce((acc, ke return acc; }, {} as Record); + +export const HUBSPOT_PORTAL_ID = window._env_.REACT_APP_HUBSPOT_EMBED || process.env.REACT_APP_HUBSPOT_EMBED; diff --git a/libs/shared-web/src/index.ts b/libs/shared-web/src/index.ts index d1df7e9f46..626bbdcc37 100644 --- a/libs/shared-web/src/index.ts +++ b/libs/shared-web/src/index.ts @@ -3,3 +3,4 @@ export * from './api'; export * from './hooks'; export * from './providers'; export * from './constants'; +export * from './components'; diff --git a/libs/shared/src/types/organization/index.ts b/libs/shared/src/types/organization/index.ts index 7c49ccf5c8..7ee58494dc 100644 --- a/libs/shared/src/types/organization/index.ts +++ b/libs/shared/src/types/organization/index.ts @@ -22,6 +22,8 @@ export enum JobTitleEnum { ARCHITECT = 'architect', PRODUCT_MANAGER = 'product_manager', DESIGNER = 'designer', + FOUNDER = 'cxo_founder', + MARKETING_MANAGER = 'marketing_manager', OTHER = 'other', } @@ -31,5 +33,7 @@ export const jobTitleToLabelMapper = { [JobTitleEnum.PRODUCT_MANAGER]: 'Product Manager', [JobTitleEnum.DESIGNER]: 'Designer', [JobTitleEnum.ENGINEERING_MANAGER]: 'Engineering Manager', + [JobTitleEnum.FOUNDER]: 'CXO Founder', + [JobTitleEnum.MARKETING_MANAGER]: 'Marketing Manager', [JobTitleEnum.OTHER]: 'Other', }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4bf77feb36..d1a10ce56f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2740,6 +2740,9 @@ importers: libs/shared-web: dependencies: + '@emotion/styled': + specifier: ^11.6.0 + version: 11.10.6(@emotion/react@11.10.6)(@types/react@17.0.62)(react@17.0.2) '@mantine/hooks': specifier: ^5.7.1 version: 5.10.5(react@17.0.2) @@ -16225,7 +16228,7 @@ packages: '@types/react': optional: true dependencies: - '@babel/runtime': 7.21.0 + '@babel/runtime': 7.23.2 '@emotion/babel-plugin': 11.10.6 '@emotion/is-prop-valid': 1.2.0 '@emotion/react': 11.10.6(@types/react@17.0.62)(react@17.0.2) @@ -53886,7 +53889,7 @@ packages: jest-worker: 26.6.2 rollup: 2.79.1 serialize-javascript: 4.0.0 - terser: 5.22.0 + terser: 5.16.9 dev: true /rollup-plugin-terser@7.0.2(rollup@3.20.2):