Skip to content

Commit

Permalink
feat(web): Onboarding funnel updates (#5991)
Browse files Browse the repository at this point in the history
* feat: onboarding updates

* fix: language backend pass

* fix: update questionnaire

* fix: cspell
  • Loading branch information
scopsy authored Jul 5, 2024
1 parent 69688f7 commit 6c411d2
Show file tree
Hide file tree
Showing 20 changed files with 130 additions and 131 deletions.
3 changes: 2 additions & 1 deletion .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -748,6 +748,7 @@
".env.production",
".env.test",
".example.env",
"pnpm-lock.yaml"
"pnpm-lock.yaml",
"apps/web/env.sh"
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,6 @@ export class UserRegisterCommand extends BaseCommand {

@IsOptional()
productUseCases?: ProductUseCases;

language?: string[];
}
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export class UserRegister {
userId: user._id,
jobTitle: command.jobTitle,
domain: command.domain,
productUseCases: command.productUseCases,
language: command.language,
})
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,5 @@ export class CreateOrganizationDto implements ICreateOrganizationDto {
domain?: string;

@IsOptional()
productUseCases?: ProductUseCases;
language?: string[];
}
6 changes: 3 additions & 3 deletions apps/api/src/app/organization/e2e/create-organization.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ describe('Create Organization - /organizations (POST)', async () => {
it('should create organization with questionnaire data', async () => {
const testOrganization: ICreateOrganizationDto = {
name: 'Org Name',
productUseCases: {
language: {
in_app: true,
multi_channel: true,
},
Expand All @@ -89,8 +89,8 @@ describe('Create Organization - /organizations (POST)', async () => {

expect(dbOrganization?.name).to.eq(testOrganization.name);
expect(dbOrganization?.domain).to.eq(testOrganization.domain);
expect(dbOrganization?.productUseCases?.in_app).to.eq(testOrganization.productUseCases?.in_app);
expect(dbOrganization?.productUseCases?.multi_channel).to.eq(testOrganization.productUseCases?.multi_channel);
expect(dbOrganization?.productUseCases?.in_app).to.eq(testOrganization.language?.in_app);
expect(dbOrganization?.productUseCases?.multi_channel).to.eq(testOrganization.language?.multi_channel);
});

it('should update user job title on organization creation', async () => {
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/app/organization/organization.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ export class OrganizationController {
name: body.name,
jobTitle: body.jobTitle,
domain: body.domain,
productUseCases: body.productUseCases,
language: body.language,
})
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,5 @@ export class CreateOrganizationCommand extends AuthenticatedCommand {
domain?: string;

@IsOptional()
productUseCases?: ProductUseCases;
language?: string[];
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export class CreateOrganization {
name: command.name,
apiServiceLevel: ApiServiceLevelEnum.FREE,
domain: command.domain,
productUseCases: command.productUseCases,
language: command.language,
});

if (command.jobTitle) {
Expand Down Expand Up @@ -92,6 +92,8 @@ export class CreateOrganization {

this.analyticsService.track('[Authentication] - Create Organization', user._id, {
_organization: createdOrganization._id,
language: command.language,
creatorJobTitle: command.jobTitle,
});

const organizationAfterChanges = await this.getOrganizationUsecase.execute(
Expand Down
26 changes: 17 additions & 9 deletions apps/web/src/hooks/useAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,24 +115,32 @@ export function useAuth() {
navigate(ROUTES.AUTH_LOGIN);
}, [navigate, queryClient, segment]);

const redirectTo = useCallback(({ url, redirectURL }: { url: string; redirectURL?: string }) => {
const finalURL = new URL(url, window.location.origin);
const redirectTo = useCallback(
({ url, redirectURL, origin }: { url: string; redirectURL?: string; origin?: string }) => {
const finalURL = new URL(url, window.location.origin);

if (redirectURL) {
finalURL.searchParams.append('redirect_url', redirectURL);
}
if (redirectURL) {
finalURL.searchParams.append('redirect_url', redirectURL);
}

if (origin) {
finalURL.searchParams.append('origin', origin);
}

// Note: Do not use react-router-dom. The version we have doesn't do instant cross origin redirects.
window.location.replace(finalURL.href);
}, []);
// Note: Do not use react-router-dom. The version we have doesn't do instant cross origin redirects.
window.location.replace(finalURL.href);
},
[]
);

const redirectToLogin = useCallback(
({ redirectURL }: { redirectURL?: string } = {}) => redirectTo({ url: ROUTES.AUTH_LOGIN, redirectURL }),
[redirectTo]
);

const redirectToSignUp = useCallback(
({ redirectURL }: { redirectURL?: string } = {}) => redirectTo({ url: ROUTES.AUTH_SIGNUP, redirectURL }),
({ redirectURL, origin }: { redirectURL?: string; origin?: string } = {}) =>
redirectTo({ url: ROUTES.AUTH_SIGNUP, redirectURL, origin }),
[redirectTo]
);

Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/pages/auth/QuestionnairePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export default function QuestionnairePage() {
}

return (
<AuthLayout title="Tell us more about you">
<AuthLayout title="Let's create your organization">
{isHubspotEnabled ? <HubspotSignupForm /> : <QuestionnaireForm />}
</AuthLayout>
);
Expand Down
127 changes: 59 additions & 68 deletions apps/web/src/pages/auth/components/QuestionnaireForm.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';
import { Controller, useForm } from 'react-hook-form';
import { useMutation } from '@tanstack/react-query';
import { Group, Input as MantineInput } from '@mantine/core';
Expand Down Expand Up @@ -41,6 +41,7 @@ export function QuestionnaireForm() {
const { isFromVercel } = useVercelParams();
const { parse } = useDomainParser();
const segment = useSegment();
const location = useLocation();

const { mutateAsync: createOrganizationMutation } = useMutation<
{ _id: string },
Expand All @@ -60,13 +61,23 @@ export function QuestionnaireForm() {

async function createOrganization(data: IOrganizationCreateForm) {
const { organizationName, ...rest } = data;
const createDto: ICreateOrganizationDto = { ...rest, name: organizationName };
const selectedLanguages = Object.keys(data.language || {}).filter((key) => data.language && data.language[key]);

const createDto: ICreateOrganizationDto = {
...rest,
name: organizationName,
language: selectedLanguages,
};
const organization = await createOrganizationMutation(createDto);

const organizationResponseToken = await api.post(`/v1/auth/organizations/${organization._id}/switch`, {});
await login(organizationResponseToken);

segment.track('Create Organization Form Submitted');
segment.track('Create Organization Form Submitted', {
location: (location.state as any)?.origin || 'web',
language: selectedLanguages,
jobTitle: data.jobTitle,
});
}

const onCreateOrganization = async (data: IOrganizationCreateForm) => {
Expand All @@ -90,9 +101,8 @@ export function QuestionnaireForm() {

return;
}
const firstUsecase = findFirstUsecase(data.productUseCases) ?? '';
const mappedUsecase = firstUsecase.replace('_', '-');
navigate(`${ROUTES.GET_STARTED}?tab=${mappedUsecase}`);

navigate(`${ROUTES.GET_STARTED}`);
};

/**
Expand All @@ -108,31 +118,6 @@ export function QuestionnaireForm() {

return (
<form noValidate name="create-app-form" onSubmit={handleSubmit(onCreateOrganization)}>
<Controller
name="jobTitle"
control={control}
rules={{
required: 'Please specify your job title',
}}
render={({ field }) => {
return (
<StyledSelect
label="Job title"
data-test-id="questionnaire-job-title"
error={errors.jobTitle?.message}
{...field}
allowDeselect={false}
placeholder="Select an option"
data={Object.values(JobTitleEnum).map((item) => ({
label: jobTitleToLabelMapper[item],
value: item,
}))}
required
/>
);
}}
/>

<Controller
name="organizationName"
control={control}
Expand All @@ -155,49 +140,45 @@ export function QuestionnaireForm() {
/>

<Controller
name="domain"
name="jobTitle"
control={control}
rules={{
validate: {
isValiDomain: (value) => {
const val = parse(value as string);

if (value && !val.isIcann) {
return 'Please provide a valid domain';
}
},
},
required: 'Please specify your job title',
}}
render={({ field }) => {
return (
<Input
label="Company domain"
<StyledSelect
label="Job title"
data-test-id="questionnaire-job-title"
error={errors.jobTitle?.message}
{...field}
error={errors.domain?.message}
placeholder="my-company.com"
data-test-id="questionnaire-company-domain"
mt={32}
allowDeselect={false}
placeholder="Select an option"
data={Object.values(JobTitleEnum).map((item) => ({
label: jobTitleToLabelMapper[item],
value: item,
}))}
required
/>
);
}}
/>

<Controller
name="productUseCases"
name="language"
control={control}
rules={{
required: 'Please specify your use case',
}}
render={({ field, fieldState }) => {
function handleCheckboxChange(e, channelType) {
const newUseCases: ProductUseCases = field.value || {};
newUseCases[channelType] = e.currentTarget.checked;
field.onChange(newUseCases);
const languages = field.value || {};

languages[channelType] = e.currentTarget.checked;

field.onChange(languages);
}

return (
<MantineInput.Wrapper
label="What do you plan to use Novu for?"
label="Choose your back-end stack"
styles={inputStyles}
error={fieldState.error?.message}
mt={32}
Expand All @@ -206,16 +187,14 @@ export function QuestionnaireForm() {
<Group
mt={8}
mx={'8px'}
style={{ marginLeft: '-12px', marginRight: '-12px', gap: '0', justifyContent: 'space-between' }}
style={{ marginLeft: '-1px', marginRight: '-3px', gap: '0', justifyContent: 'space-between' }}
>
<>
{checkBoxData.map((item) => (
{backendLanguages.map((item) => (
<DynamicCheckBox
Icon={item.icon}
label={item.label}
onChange={(e) => handleCheckboxChange(e, item.type)}
key={item.type}
type={item.type}
onChange={(e) => handleCheckboxChange(e, item.label)}
key={item.label}
/>
))}
</>
Expand All @@ -231,19 +210,31 @@ export function QuestionnaireForm() {
);
}

const checkBoxData = [
{ type: ProductUseCasesEnum.IN_APP, icon: RingingBell, label: 'In-app' },
{ type: ProductUseCasesEnum.MULTI_CHANNEL, icon: MultiChannel, label: 'Multi-channel' },
{ type: ProductUseCasesEnum.DIGEST, icon: Digest, label: 'Digest' },
{ type: ProductUseCasesEnum.DELAY, icon: HalfClock, label: 'Delay' },
{ type: ProductUseCasesEnum.TRANSLATION, icon: Translation, label: 'Translate' },
const backendLanguages = [
{ label: 'Node.js' },
{ label: 'Python' },
{ label: 'Go' },
{ label: 'PHP' },
{ label: 'Rust' },
{ label: 'Java' },
{ label: 'Other' },
];

const frontendFrameworks = [
{ label: 'React' },
{ label: 'Vue' },
{ label: 'Angular' },
{ label: 'Flutter' },
{ label: 'React Native' },
{ label: 'Other' },
];

interface IOrganizationCreateForm {
organizationName: string;
jobTitle: JobTitleEnum;
domain?: string;
productUseCases?: ProductUseCases;
language?: string[];
frontendStack?: string[];
}

function findFirstUsecase(useCases: ProductUseCases | undefined): ProductUseCasesEnum | undefined {
Expand Down
13 changes: 11 additions & 2 deletions apps/web/src/pages/auth/components/SignUpForm.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { useMutation } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { Center } from '@mantine/core';
Expand Down Expand Up @@ -31,6 +31,7 @@ export function SignUpForm({ invitationToken, email }: SignUpFormProps) {
const { setRedirectURL } = useRedirectURL();
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => setRedirectURL(), []);
const location = useLocation();

const { login } = useAuth();
const { isLoading: isAcceptInviteLoading, acceptInvite } = useAcceptInvite();
Expand All @@ -49,12 +50,16 @@ export function SignUpForm({ invitationToken, email }: SignUpFormProps) {
>((data) => api.post('/v1/auth/register', data));

const onSubmit = async (data) => {
const parsedSearchParams = new URLSearchParams(location.search);
const origin = parsedSearchParams.get('origin');

const [firstName, lastName] = data?.fullName.trim().split(' ');
const itemData = {
firstName,
lastName,
email: data.email,
password: data.password,
origin: origin,
};

const response = await mutateAsync(itemData);
Expand All @@ -68,7 +73,11 @@ export function SignUpForm({ invitationToken, email }: SignUpFormProps) {
}
navigate(ROUTES.AUTH_APPLICATION);
} else {
navigate(isFromVercel ? `${ROUTES.AUTH_APPLICATION}?${params.toString()}` : ROUTES.AUTH_APPLICATION);
navigate(isFromVercel ? `${ROUTES.AUTH_APPLICATION}?${params.toString()}` : ROUTES.AUTH_APPLICATION, {
state: {
origin,
},
});
}
};

Expand Down
Loading

0 comments on commit 6c411d2

Please sign in to comment.