2
0

feat(workspace): 🚸 Improve plan upgrade flow

This commit is contained in:
Baptiste Arnaud
2022-06-01 12:09:45 +02:00
parent caa6015359
commit 87fe47923e
11 changed files with 218 additions and 65 deletions

View File

@@ -25,6 +25,7 @@ import { useUser } from 'contexts/UserContext'
import { useWorkspace } from 'contexts/WorkspaceContext' import { useWorkspace } from 'contexts/WorkspaceContext'
import { EmojiOrImageIcon } from 'components/shared/EmojiOrImageIcon' import { EmojiOrImageIcon } from 'components/shared/EmojiOrImageIcon'
import { WorkspaceSettingsModal } from './WorkspaceSettingsModal' import { WorkspaceSettingsModal } from './WorkspaceSettingsModal'
import { isNotDefined } from 'utils'
export const DashboardHeader = () => { export const DashboardHeader = () => {
const { user } = useUser() const { user } = useUser()
@@ -66,7 +67,11 @@ export const DashboardHeader = () => {
workspace={workspace} workspace={workspace}
/> />
)} )}
<Button leftIcon={<SettingsIcon />} onClick={onOpen}> <Button
leftIcon={<SettingsIcon />}
onClick={onOpen}
isLoading={isNotDefined(workspace)}
>
Settings & Members Settings & Members
</Button> </Button>
<Menu placement="bottom-end"> <Menu placement="bottom-end">

View File

@@ -1,6 +1,7 @@
import { Stack, HStack, Button, Text, Tag } from '@chakra-ui/react' import { Stack, HStack, Button, Text, Tag } from '@chakra-ui/react'
import { ExternalLinkIcon } from 'assets/icons' import { ExternalLinkIcon } from 'assets/icons'
import { NextChakraLink } from 'components/nextChakra/NextChakraLink' import { NextChakraLink } from 'components/nextChakra/NextChakraLink'
import { UpgradeButton } from 'components/shared/buttons/UpgradeButton'
import { useWorkspace } from 'contexts/WorkspaceContext' import { useWorkspace } from 'contexts/WorkspaceContext'
import { Plan } from 'db' import { Plan } from 'db'
import React from 'react' import React from 'react'
@@ -9,11 +10,33 @@ export const BillingForm = () => {
const { workspace } = useWorkspace() const { workspace } = useWorkspace()
return ( return (
<Stack spacing="6"> <Stack spacing="6" w="full">
<HStack> <HStack>
<Text>Workspace subscription: </Text> <Text>Current workspace subscription: </Text>
<PlanTag plan={workspace?.plan} /> <PlanTag plan={workspace?.plan} />
</HStack> </HStack>
{workspace &&
!([Plan.TEAM, Plan.LIFETIME, Plan.OFFERED] as Plan[]).includes(
workspace.plan
) && (
<HStack>
{workspace?.plan === Plan.FREE && (
<UpgradeButton colorScheme="orange" variant="outline" w="full">
Upgrade to Pro plan
</UpgradeButton>
)}
{workspace?.plan !== Plan.TEAM && (
<UpgradeButton
colorScheme="purple"
variant="outline"
w="full"
plan={Plan.TEAM}
>
Upgrade to Team plan
</UpgradeButton>
)}
</HStack>
)}
{workspace?.stripeId && ( {workspace?.stripeId && (
<> <>
<Text> <Text>

View File

@@ -1,16 +1,25 @@
import { Button, ButtonProps, useDisclosure } from '@chakra-ui/react' import { Button, ButtonProps, useDisclosure } from '@chakra-ui/react'
import { useWorkspace } from 'contexts/WorkspaceContext'
import { Plan } from 'db'
import React from 'react' import React from 'react'
import { isNotDefined } from 'utils'
import { UpgradeModal } from '../modals/UpgradeModal' import { UpgradeModal } from '../modals/UpgradeModal'
import { LimitReached } from '../modals/UpgradeModal/UpgradeModal' import { LimitReached } from '../modals/UpgradeModal/UpgradeModal'
type Props = { type?: LimitReached } & ButtonProps type Props = { plan?: Plan; type?: LimitReached } & ButtonProps
export const UpgradeButton = ({ type, ...props }: Props) => { export const UpgradeButton = ({ type, plan = Plan.PRO, ...props }: Props) => {
const { isOpen, onOpen, onClose } = useDisclosure() const { isOpen, onOpen, onClose } = useDisclosure()
const { workspace } = useWorkspace()
return ( return (
<Button colorScheme="blue" {...props} onClick={onOpen}> <Button
colorScheme="blue"
{...props}
isLoading={isNotDefined(workspace)}
onClick={onOpen}
>
{props.children ?? 'Upgrade'} {props.children ?? 'Upgrade'}
<UpgradeModal isOpen={isOpen} onClose={onClose} type={type} /> <UpgradeModal isOpen={isOpen} onClose={onClose} type={type} plan={plan} />
</Button> </Button>
) )
} }

View File

@@ -16,6 +16,7 @@ import {
ListProps, ListProps,
Button, Button,
HStack, HStack,
useToast,
} from '@chakra-ui/react' } from '@chakra-ui/react'
import { pay } from 'services/stripe' import { pay } from 'services/stripe'
import { useUser } from 'contexts/UserContext' import { useUser } from 'contexts/UserContext'
@@ -23,6 +24,7 @@ import { Plan } from 'db'
import { useWorkspace } from 'contexts/WorkspaceContext' import { useWorkspace } from 'contexts/WorkspaceContext'
import { TypebotLogo } from 'assets/logos' import { TypebotLogo } from 'assets/logos'
import { CheckIcon } from 'assets/icons' import { CheckIcon } from 'assets/icons'
import { toTitleCase } from 'utils'
export enum LimitReached { export enum LimitReached {
BRAND = 'Remove branding', BRAND = 'Remove branding',
@@ -43,9 +45,10 @@ export const UpgradeModal = ({
plan = Plan.PRO, plan = Plan.PRO,
}: UpgradeModalProps) => { }: UpgradeModalProps) => {
const { user } = useUser() const { user } = useUser()
const { workspace } = useWorkspace() const { workspace, refreshWorkspace } = useWorkspace()
const [payLoading, setPayLoading] = useState(false) const [payLoading, setPayLoading] = useState(false)
const [currency, setCurrency] = useState<'usd' | 'eur'>('usd') const [currency, setCurrency] = useState<'usd' | 'eur'>('usd')
const toast = useToast()
useEffect(() => { useEffect(() => {
setCurrency( setCurrency(
@@ -56,12 +59,24 @@ export const UpgradeModal = ({
const handlePayClick = async () => { const handlePayClick = async () => {
if (!user || !workspace) return if (!user || !workspace) return
setPayLoading(true) setPayLoading(true)
await pay({ const response = await pay({
customerId: workspace.stripeId ?? undefined,
user, user,
currency, currency,
plan: plan === Plan.TEAM ? 'team' : 'pro', plan: plan === Plan.TEAM ? 'team' : 'pro',
workspaceId: workspace.id, workspaceId: workspace.id,
}) })
setPayLoading(false)
if (response?.newPlan) {
refreshWorkspace({ plan: response.newPlan })
toast({
status: 'success',
title: 'Upgrade success!',
description: `Workspace successfully upgraded to ${toTitleCase(
response.newPlan
)} plan 🎉`,
})
}
} }
return ( return (

View File

@@ -32,6 +32,7 @@ const workspaceContext = createContext<{
updates: Partial<Workspace> updates: Partial<Workspace>
) => Promise<void> ) => Promise<void>
deleteCurrentWorkspace: () => Promise<void> deleteCurrentWorkspace: () => Promise<void>
refreshWorkspace: (expectedUpdates: Partial<Workspace>) => void
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore //@ts-ignore
}>({}) }>({})
@@ -141,6 +142,17 @@ export const WorkspaceContext = ({ children }: { children: ReactNode }) => {
}) })
} }
const refreshWorkspace = (expectedUpdates: Partial<Workspace>) => {
if (!currentWorkspace) return
const updatedWorkspace = { ...currentWorkspace, ...expectedUpdates }
mutate({
workspaces: (workspaces ?? []).map((w) =>
w.id === currentWorkspace.id ? updatedWorkspace : w
),
})
setCurrentWorkspace(updatedWorkspace)
}
return ( return (
<workspaceContext.Provider <workspaceContext.Provider
value={{ value={{
@@ -153,6 +165,7 @@ export const WorkspaceContext = ({ children }: { children: ReactNode }) => {
createWorkspace, createWorkspace,
updateWorkspace, updateWorkspace,
deleteCurrentWorkspace, deleteCurrentWorkspace,
refreshWorkspace,
}} }}
> >
{children} {children}

View File

@@ -1,7 +1,7 @@
import React, { useEffect } from 'react' import React, { useEffect } from 'react'
import { AppProps } from 'next/app' import { AppProps } from 'next/app'
import { SessionProvider } from 'next-auth/react' import { SessionProvider } from 'next-auth/react'
import { ChakraProvider } from '@chakra-ui/react' import { ChakraProvider, createStandaloneToast } from '@chakra-ui/react'
import { customTheme } from 'libs/theme' import { customTheme } from 'libs/theme'
import { useRouterProgressBar } from 'libs/routerProgressBar' import { useRouterProgressBar } from 'libs/routerProgressBar'
import 'assets/styles/routerProgressBar.css' import 'assets/styles/routerProgressBar.css'
@@ -18,12 +18,15 @@ import { actions } from 'libs/kbar'
import { enableMocks } from 'mocks' import { enableMocks } from 'mocks'
import { SupportBubble } from 'components/shared/SupportBubble' import { SupportBubble } from 'components/shared/SupportBubble'
import { WorkspaceContext } from 'contexts/WorkspaceContext' import { WorkspaceContext } from 'contexts/WorkspaceContext'
import { toTitleCase } from 'utils'
const { ToastContainer, toast } = createStandaloneToast(customTheme)
if (process.env.NEXT_PUBLIC_E2E_TEST === 'enabled') enableMocks() if (process.env.NEXT_PUBLIC_E2E_TEST === 'enabled') enableMocks()
const App = ({ Component, pageProps: { session, ...pageProps } }: AppProps) => { const App = ({ Component, pageProps: { session, ...pageProps } }: AppProps) => {
useRouterProgressBar() useRouterProgressBar()
const { query, pathname } = useRouter() const { query, pathname, isReady } = useRouter()
useEffect(() => { useEffect(() => {
pathname.endsWith('/edit') pathname.endsWith('/edit')
@@ -31,30 +34,52 @@ const App = ({ Component, pageProps: { session, ...pageProps } }: AppProps) => {
: (document.body.style.overflow = 'auto') : (document.body.style.overflow = 'auto')
}, [pathname]) }, [pathname])
useEffect(() => {
displayStripeCallbackMessage(query.stripe?.toString(), toast)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isReady])
const typebotId = query.typebotId?.toString() const typebotId = query.typebotId?.toString()
return ( return (
<ChakraProvider theme={customTheme}> <>
<KBarProvider actions={actions}> <ToastContainer />
<SessionProvider session={session}> <ChakraProvider theme={customTheme}>
<UserContext> <KBarProvider actions={actions}>
{typebotId ? ( <SessionProvider session={session}>
<TypebotContext typebotId={typebotId}> <UserContext>
{typebotId ? (
<TypebotContext typebotId={typebotId}>
<WorkspaceContext>
<Component />
<SupportBubble />
</WorkspaceContext>
</TypebotContext>
) : (
<WorkspaceContext> <WorkspaceContext>
<Component /> <Component {...pageProps} />
<SupportBubble /> <SupportBubble />
</WorkspaceContext> </WorkspaceContext>
</TypebotContext> )}
) : ( </UserContext>
<WorkspaceContext> </SessionProvider>
<Component {...pageProps} /> </KBarProvider>
<SupportBubble /> </ChakraProvider>
</WorkspaceContext> </>
)}
</UserContext>
</SessionProvider>
</KBarProvider>
</ChakraProvider>
) )
} }
const displayStripeCallbackMessage = (
status: string | undefined,
toast: any
) => {
if (status && ['pro', 'team'].includes(status)) {
toast({
position: 'bottom-right',
status: 'success',
title: 'Upgrade success!',
description: `Workspace upgraded to ${toTitleCase(status)} 🎉`,
})
}
}
export default App export default App

View File

@@ -10,12 +10,12 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: '2020-08-27', apiVersion: '2020-08-27',
}) })
const { email, currency, plan, workspaceId } = const { email, currency, plan, workspaceId, href } =
typeof req.body === 'string' ? JSON.parse(req.body) : req.body typeof req.body === 'string' ? JSON.parse(req.body) : req.body
const session = await stripe.checkout.sessions.create({ const session = await stripe.checkout.sessions.create({
success_url: `${req.headers.origin}/typebots?stripe=success`, success_url: `${href}?stripe=${plan}`,
cancel_url: `${req.headers.origin}/typebots?stripe=cancel`, cancel_url: `${href}?stripe=cancel`,
automatic_tax: { enabled: true }, automatic_tax: { enabled: true },
allow_promotion_codes: true, allow_promotion_codes: true,
customer_email: email, customer_email: email,
@@ -33,7 +33,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
return methodNotAllowed(res) return methodNotAllowed(res)
} }
const getPrice = (plan: 'pro' | 'team', currency: 'eur' | 'usd') => { export const getPrice = (plan: 'pro' | 'team', currency: 'eur' | 'usd') => {
if (plan === 'team') if (plan === 'team')
return currency === 'eur' return currency === 'eur'
? process.env.STRIPE_PRICE_TEAM_EUR_ID ? process.env.STRIPE_PRICE_TEAM_EUR_ID

View File

@@ -0,0 +1,46 @@
import { withSentry } from '@sentry/nextjs'
import { Plan } from 'db'
import prisma from 'libs/prisma'
import { NextApiRequest, NextApiResponse } from 'next'
import Stripe from 'stripe'
import { badRequest, methodNotAllowed } from 'utils'
import { getPrice } from './checkout'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === 'POST') {
const { customerId, currency, plan, workspaceId } =
typeof req.body === 'string' ? JSON.parse(req.body) : req.body
if (!process.env.STRIPE_SECRET_KEY)
throw Error('STRIPE_SECRET_KEY var is missing')
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: '2020-08-27',
})
const subscriptions = await stripe.subscriptions.list({
customer: customerId,
})
const { id, items } = subscriptions.data[0]
const newPrice = getPrice(plan, currency)
const oldPrice = subscriptions.data[0].items.data[0].price.id
if (newPrice === oldPrice) return badRequest(res)
await stripe.subscriptions.update(id, {
cancel_at_period_end: false,
proration_behavior: 'create_prorations',
items: [
{
id: items.data[0].id,
price: getPrice(plan, currency),
},
],
})
await prisma.workspace.update({
where: { id: workspaceId },
data: {
plan: plan === 'team' ? Plan.TEAM : Plan.PRO,
},
})
return res.send({ message: 'success' })
}
methodNotAllowed(res)
}
export default withSentry(handler)

View File

@@ -5,8 +5,7 @@ import { Seo } from 'components/Seo'
import { FolderContent } from 'components/dashboard/FolderContent' import { FolderContent } from 'components/dashboard/FolderContent'
import { TypebotDndContext } from 'contexts/TypebotDndContext' import { TypebotDndContext } from 'contexts/TypebotDndContext'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { redeemCoupon } from 'services/coupons' import { Spinner } from '@chakra-ui/react'
import { Spinner, useToast } from '@chakra-ui/react'
import { pay } from 'services/stripe' import { pay } from 'services/stripe'
import { useUser } from 'contexts/UserContext' import { useUser } from 'contexts/UserContext'
import { NextPageContext } from 'next/types' import { NextPageContext } from 'next/types'
@@ -14,13 +13,9 @@ import { useWorkspace } from 'contexts/WorkspaceContext'
const DashboardPage = () => { const DashboardPage = () => {
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const { query, isReady } = useRouter() const { query } = useRouter()
const { user } = useUser() const { user } = useUser()
const { workspace } = useWorkspace() const { workspace } = useWorkspace()
const toast = useToast({
position: 'top-right',
status: 'success',
})
useEffect(() => { useEffect(() => {
const subscribePlan = query.subscribePlan as 'pro' | 'team' | undefined const subscribePlan = query.subscribePlan as 'pro' | 'team' | undefined
@@ -37,25 +32,6 @@ const DashboardPage = () => {
} }
}, [query, user, workspace]) }, [query, user, workspace])
useEffect(() => {
if (!isReady) return
const couponCode = query.coupon?.toString()
const stripeStatus = query.stripe?.toString()
if (stripeStatus === 'success')
toast({
title: 'Payment successful',
description: "You've successfully subscribed 🎉",
})
if (couponCode) {
setIsLoading(true)
redeemCoupon(couponCode).then(() => {
location.href = '/typebots'
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isReady])
return ( return (
<Stack minH="100vh"> <Stack minH="100vh">
<Seo title="My typebots" /> <Seo title="My typebots" />

View File

@@ -1,25 +1,60 @@
import { User } from 'db' import { Plan, User } from 'db'
import { loadStripe } from '@stripe/stripe-js/pure' import { loadStripe } from '@stripe/stripe-js/pure'
import { isEmpty, sendRequest } from 'utils' import { isDefined, isEmpty, sendRequest } from 'utils'
type Props = { type Props = {
user: User user: User
customerId?: string
currency: 'usd' | 'eur' currency: 'usd' | 'eur'
plan: 'pro' | 'team' plan: 'pro' | 'team'
workspaceId: string workspaceId: string
} }
export const pay = async ({ user, currency, plan, workspaceId }: Props) => { export const pay = async ({
customerId,
...props
}: Props): Promise<{ newPlan: Plan } | undefined | void> =>
isDefined(customerId)
? updatePlan({ ...props, customerId })
: redirectToCheckout(props)
const updatePlan = async ({
customerId,
plan,
workspaceId,
currency,
}: Omit<Props, 'user'>): Promise<{ newPlan: Plan } | undefined> => {
const { data, error } = await sendRequest<{ message: string }>({
method: 'POST',
url: '/api/stripe/update-subscription',
body: { workspaceId, plan, customerId, currency },
})
if (error || !data) return
return { newPlan: plan === 'team' ? Plan.TEAM : Plan.PRO }
}
const redirectToCheckout = async ({
user,
currency,
plan,
workspaceId,
}: Omit<Props, 'customerId'>) => {
if (isEmpty(process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY)) if (isEmpty(process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY))
throw new Error('NEXT_PUBLIC_STRIPE_PUBLIC_KEY is missing in env') throw new Error('NEXT_PUBLIC_STRIPE_PUBLIC_KEY is missing in env')
const stripe = await loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY)
const { data, error } = await sendRequest<{ sessionId: string }>({ const { data, error } = await sendRequest<{ sessionId: string }>({
method: 'POST', method: 'POST',
url: '/api/stripe/checkout', url: '/api/stripe/checkout',
body: { email: user.email, currency, plan, workspaceId }, body: {
email: user.email,
currency,
plan,
workspaceId,
href: location.origin + location.pathname,
},
}) })
if (error || !data) return if (error || !data) return
return stripe?.redirectToCheckout({ const stripe = await loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY)
await stripe?.redirectToCheckout({
sessionId: data?.sessionId, sessionId: data?.sessionId,
}) })
} }

View File

@@ -171,3 +171,9 @@ export const sanitizeUrl = (url: string): string =>
url.startsWith('sms:') url.startsWith('sms:')
? url ? url
: `https://${url}` : `https://${url}`
export const toTitleCase = (str: string) =>
str.replace(
/\w\S*/g,
(txt) => txt.charAt(0).toUpperCase() + txt.slice(1).toLowerCase()
)