Skip to content

Commit

Permalink
feat(onboarding): add new step pricing (#425)
Browse files Browse the repository at this point in the history
  • Loading branch information
RemiBonnet committed Dec 21, 2022
1 parent 105bd15 commit 6dc1e88
Show file tree
Hide file tree
Showing 19 changed files with 193 additions and 467 deletions.
3 changes: 0 additions & 3 deletions libs/domains/organization/src/index.ts
Expand Up @@ -9,6 +9,3 @@ export * from './lib/mocks/organizations-handler.mock'
export * from './lib/mocks/cluster-factory.mock'
export * from './lib/mocks/cluster-log-factory.mock'
export * from './lib/mocks/auth-provider.mock'
export * from './lib/interfaces/organization-plan.interface'
export * from './lib/interfaces/organization-price.interface'
export * from './lib/enums/organization-plan-type.enum'

This file was deleted.

This file was deleted.

14 changes: 6 additions & 8 deletions libs/domains/organization/src/lib/slices/organization.slice.ts
Expand Up @@ -53,13 +53,9 @@ export const fetchOrganizationById = createAsyncThunk(

export const postOrganization = createAsyncThunk<OrganizationEntity, OrganizationRequest>(
'organization/post',
async (data: OrganizationRequest, { rejectWithValue }) => {
try {
const result = await organizationMainCalls.createOrganization(data)
return result.data
} catch (err) {
return rejectWithValue(err)
}
async (data: OrganizationRequest) => {
const result = await organizationMainCalls.createOrganization(data)
return result.data
}
)

Expand Down Expand Up @@ -259,12 +255,14 @@ export const organizationSlice = createSlice({
state.loadingStatus = 'loading'
})
.addCase(postOrganization.fulfilled, (state: OrganizationState, action: PayloadAction<OrganizationEntity>) => {
organizationAdapter.setOne(state, action.payload)
organizationAdapter.addOne(state, action.payload)
state.loadingStatus = 'loaded'
toast(ToastEnum.SUCCESS, 'Your organization has been created')
})
.addCase(postOrganization.rejected, (state: OrganizationState, action) => {
state.loadingStatus = 'error'
state.error = action.error.message
toastError(action.error)
})
// delete organization
.addCase(deleteOrganization.pending, (state: OrganizationState) => {
Expand Down
43 changes: 17 additions & 26 deletions libs/pages/onboarding/src/lib/feature/container/container.tsx
@@ -1,14 +1,9 @@
import { createContext, useEffect, useState } from 'react'
import { Params, useNavigate } from 'react-router-dom'
import {
ONBOARDING_PRICING_FREE_URL,
ONBOARDING_PRICING_URL,
ONBOARDING_PROJECT_URL,
ONBOARDING_URL,
Route,
} from '@qovery/shared/router'
import { ONBOARDING_PRICING_URL, ONBOARDING_PROJECT_URL, Route } from '@qovery/shared/router'
import { FunnelFlow, FunnelFlowBody } from '@qovery/shared/ui'
import { ROUTER_ONBOARDING_STEP_1, ROUTER_ONBOARDING_STEP_2 } from '../../router/router'
import { LayoutOnboarding } from '../../ui/layout-onboarding/layout-onboarding'
import OnboardingRightContent from '../../ui/onboarding-right-content/onboarding-right-content'

interface DefaultContextProps {
organization_name: string
Expand Down Expand Up @@ -40,20 +35,12 @@ export function Container(props: ContainerProps) {

useEffect(() => {
setStep(params['*'])

if (step === ONBOARDING_PRICING_URL.replace('/', '')) {
navigate(`${ONBOARDING_URL}${ONBOARDING_PRICING_FREE_URL}`)
}
}, [params, setStep, step, navigate])

const stepsNumber: number = firstStep ? ROUTER_ONBOARDING_STEP_1.length : ROUTER_ONBOARDING_STEP_2.length

const currentStepPosition = (routes: Route[]) =>
routes.findIndex((route: Route) => route.path.replace('/:plan', '') === `/${step?.split('/')[0]}`) + 1

function getProgressPercentValue(): number {
return (100 * currentStepPosition(currentRoutes)) / stepsNumber
}
const isNotStepPricing = `/${step}` !== ONBOARDING_PRICING_URL

return (
<ContextOnboarding.Provider
Expand All @@ -62,22 +49,26 @@ export function Container(props: ContainerProps) {
setContextValue,
}}
>
<LayoutOnboarding
getProgressPercentValue={getProgressPercentValue()}
routes={ROUTER_ONBOARDING_STEP_2}
stepsNumber={stepsNumber}
currentStepPosition={currentStepPosition(currentRoutes)}
step={step}
catchline={
<FunnelFlow
totalSteps={currentRoutes.length}
currentStep={currentStepPosition(currentRoutes)}
currentTitle={
firstStep
? 'Just a few questions'
: `/${step}` === ONBOARDING_PROJECT_URL
? 'Organization and Project Creation'
: 'Select your plan'
}
portal
>
{children}
</LayoutOnboarding>
<FunnelFlowBody
helpSectionClassName="!p-0 !bg-transparent !border-transparent"
helpSection={isNotStepPricing && <OnboardingRightContent step={step} />}
customContentWidth={!isNotStepPricing ? 'max-w-[1096px]' : undefined}
>
{children}
</FunnelFlowBody>
</FunnelFlow>
</ContextOnboarding.Provider>
)
}
Expand Down
@@ -1,86 +1,69 @@
import { Organization, PlanEnum, Project } from 'qovery-typescript-axios'
import { useContext, useEffect, useState } from 'react'
import { useContext, useState } from 'react'
import { useDispatch } from 'react-redux'
import { useNavigate } from 'react-router-dom'
import { useIntercom } from 'react-use-intercom'
import {
OrganizationPlan,
OrganizationPlanType,
OrganizationPrice,
postOrganization,
} from '@qovery/domains/organization'
import { postOrganization } from '@qovery/domains/organization'
import { postProject } from '@qovery/domains/projects'
import { useAuth } from '@qovery/shared/auth'
import { ONBOARDING_PRICING_URL, ONBOARDING_PROJECT_URL, ONBOARDING_URL } from '@qovery/shared/router'
import { ENVIRONMENTS_GENERAL_URL, ENVIRONMENTS_URL } from '@qovery/shared/router'
import { useDocumentTitle } from '@qovery/shared/utils'
import { AppDispatch } from '@qovery/store'
import { StepPricing } from '../../ui/step-pricing/step-pricing'
import { ContextOnboarding } from '../container/container'

function listPrice(base: number, isBusinessPlan?: boolean) {
const results: OrganizationPrice[] = []
let multiple = 0

for (let i = 100; i <= 4000; i = i + 100) {
const nbDeploy = isBusinessPlan ? 1000 : 300

if (i > nbDeploy) multiple += 1
const price = i > nbDeploy ? base + 50 * multiple : base
results.push({
number: i.toString(),
price: price.toString(),
})
}
return results
export interface OrganizationPlan {
name: PlanEnum
title: string
text: string
price: number
list: string[]
}

const PLANS: OrganizationPlan[] = [
{
name: PlanEnum.FREE,
title: 'Free',
text: 'Adapted for personnal project',
title: 'Free plan',
text: 'Adapted for start',
price: 0,
listPrice: [],
list: [
'Deploy on your AWS account',
'Unlimited Developers',
'Up to 1 cluster',
'Up to 5 Environments',
'Preview Environment in one-click',
'Community support (forum)',
],
},
{
name: PlanEnum.TEAM,
title: 'Professional',
text: 'For 5-20 members',
title: 'Team plan',
text: 'Adapted to scale',
price: 49,
listPrice: listPrice(49, false),
list: [
'All FREE features',
'Up to 3 clusters',
'Unlimited Deployments',
'Unlimited Environments',
'24/5 support (email and chat)',
],
},
{
name: PlanEnum.ENTERPRISE,
title: 'Business',
text: 'For medium company',
price: 599,
listPrice: listPrice(599, true),
},
{
name: PlanEnum.ENTERPRISE,
title: 'Enterprise',
text: 'For large company',
price: 0,
listPrice: [],
title: 'Enterprise plan',
text: 'Adapted for 100+ team',
price: 899,
list: [
'All TEAM features',
'Unlimited Clusters',
'Role-Based Access Control',
'Extended security and compliance',
'Usage Report',
'Custom support',
],
},
]

const PLAN_DEFAULT: PlanEnum = PlanEnum.FREE
const DEPLOY_DEFAULT = 100

const DEFAULT_PRICE = {
[OrganizationPlanType.FREE]: { disable: false },
[OrganizationPlanType.PROFESSIONAL]: {
number: PLANS.find((p) => p.name === PlanEnum.TEAM)?.listPrice[0].number,
disable: false,
},
[OrganizationPlanType.BUSINESS]: {
number: PLANS.find((p) => p.name === PlanEnum.ENTERPRISE)?.listPrice[0].number,
disable: false,
},
[OrganizationPlanType.ENTERPRISE]: { disable: false },
}

export function OnboardingPricing() {
useDocumentTitle('Onboarding Pricing - Qovery')

Expand All @@ -89,89 +72,43 @@ export function OnboardingPricing() {
const { showNewMessages } = useIntercom()
const { organization_name, project_name } = useContext(ContextOnboarding)
const { createAuthCookies, getAccessTokenSilently } = useAuth()
const [selectPlan, setSelectPlan] = useState(PLAN_DEFAULT)
const [currentValue, setCurrentValue] = useState(DEFAULT_PRICE)
const [currentDeploy, setCurrentDeploy] = useState(DEPLOY_DEFAULT)
const [loading, setLoading] = useState(false)

useEffect(() => {
if (organization_name === '' && project_name === '') {
navigate(`${ONBOARDING_URL}${ONBOARDING_PROJECT_URL}`)
} else {
navigate(`${ONBOARDING_URL}${ONBOARDING_PRICING_URL}/${selectPlan.toLowerCase()}`)
}
}, [selectPlan, navigate, organization_name, project_name])

const chooseDeploy = (value: number | null) => {
if (value) {
setCurrentDeploy(value)

if (value > 100) {
if (selectPlan === PlanEnum.FREE) setSelectPlan(PlanEnum.TEAM)

setCurrentValue({
[OrganizationPlanType.FREE]: { disable: true },
[OrganizationPlanType.PROFESSIONAL]: { number: value.toString(), disable: false },
[OrganizationPlanType.BUSINESS]: { number: value.toString(), disable: false },
[OrganizationPlanType.ENTERPRISE]: { disable: false },
})
} else {
setCurrentValue({
[OrganizationPlanType.FREE]: { disable: false },
[OrganizationPlanType.PROFESSIONAL]: { number: value.toString(), disable: false },
[OrganizationPlanType.BUSINESS]: { number: value.toString(), disable: false },
[OrganizationPlanType.ENTERPRISE]: { disable: false },
})
}
}
}
const [loading, setLoading] = useState('')

const onSubmit = async () => {
setLoading(true)
const onSubmit = async (plan: PlanEnum) => {
setLoading(plan)

const organization: Organization = await dispatch(
await dispatch(
postOrganization({
name: organization_name,
plan: selectPlan,
plan: plan,
})
).unwrap()
// refresh token needed after created an organization
await getAccessTokenSilently({ ignoreCache: true })

if (organization) {
const project: Project = await dispatch(
postProject({ organizationId: organization.id, name: project_name })
).unwrap()

if (project) {
await createAuthCookies()
setLoading(false)

setTimeout(() => {
const url = `${process.env['NX_URL'] || 'https://console.qovery.com'}?redirectLoginV3`
window.location.replace(url)
}, 500)
}
} else {
setLoading(false)
}
)
.then(async (result) => {
// refresh token needed after created an organization
await getAccessTokenSilently({ ignoreCache: true })

const organization = result.payload as Organization

if (result.payload) {
const project: Project = await dispatch(
postProject({ organizationId: organization.id, name: project_name })
).unwrap()
if (project) {
await createAuthCookies()
setLoading('')
// redirect on the project page
navigate(ENVIRONMENTS_URL(organization.id, project.id) + ENVIRONMENTS_GENERAL_URL)
}
} else {
setLoading('')
}
})
.catch(() => setLoading(''))
}

const onClickContact = () => showNewMessages()

return (
<StepPricing
selectPlan={selectPlan}
setSelectPlan={setSelectPlan}
currentValue={currentValue}
plans={PLANS}
chooseDeploy={chooseDeploy}
currentDeploy={currentDeploy}
onSubmit={onSubmit}
loading={loading}
onClickContact={onClickContact}
/>
)
return <StepPricing plans={PLANS} onSubmit={onSubmit} loading={loading} onClickContact={onClickContact} />
}

export default OnboardingPricing
Expand Up @@ -10,14 +10,12 @@ export function OnboardingThanks() {
const { update } = useIntercom()

useEffect(() => {
// if (process.env['NODE_ENV'] === 'production') {
// update user intercom
update({
email: userSignUp?.user_email,
name: `${userSignUp?.first_name} ${userSignUp?.last_name}`,
userId: user.sub,
})
// }
}, [user, userSignUp, update])

return (
Expand Down
2 changes: 1 addition & 1 deletion libs/pages/onboarding/src/lib/router/router.tsx
Expand Up @@ -33,7 +33,7 @@ export const ROUTER_ONBOARDING_STEP_2: Route[] = [
component: <OnboardingProject />,
},
{
path: `${ONBOARDING_PRICING_URL}/:plan`,
path: ONBOARDING_PRICING_URL,
component: <OnboardingPricing />,
},
]

0 comments on commit 6dc1e88

Please sign in to comment.