feat(workspace): 🚸 Improve plan upgrade flow
This commit is contained in:
@ -25,6 +25,7 @@ import { useUser } from 'contexts/UserContext'
|
||||
import { useWorkspace } from 'contexts/WorkspaceContext'
|
||||
import { EmojiOrImageIcon } from 'components/shared/EmojiOrImageIcon'
|
||||
import { WorkspaceSettingsModal } from './WorkspaceSettingsModal'
|
||||
import { isNotDefined } from 'utils'
|
||||
|
||||
export const DashboardHeader = () => {
|
||||
const { user } = useUser()
|
||||
@ -66,7 +67,11 @@ export const DashboardHeader = () => {
|
||||
workspace={workspace}
|
||||
/>
|
||||
)}
|
||||
<Button leftIcon={<SettingsIcon />} onClick={onOpen}>
|
||||
<Button
|
||||
leftIcon={<SettingsIcon />}
|
||||
onClick={onOpen}
|
||||
isLoading={isNotDefined(workspace)}
|
||||
>
|
||||
Settings & Members
|
||||
</Button>
|
||||
<Menu placement="bottom-end">
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { Stack, HStack, Button, Text, Tag } from '@chakra-ui/react'
|
||||
import { ExternalLinkIcon } from 'assets/icons'
|
||||
import { NextChakraLink } from 'components/nextChakra/NextChakraLink'
|
||||
import { UpgradeButton } from 'components/shared/buttons/UpgradeButton'
|
||||
import { useWorkspace } from 'contexts/WorkspaceContext'
|
||||
import { Plan } from 'db'
|
||||
import React from 'react'
|
||||
@ -9,11 +10,33 @@ export const BillingForm = () => {
|
||||
const { workspace } = useWorkspace()
|
||||
|
||||
return (
|
||||
<Stack spacing="6">
|
||||
<Stack spacing="6" w="full">
|
||||
<HStack>
|
||||
<Text>Workspace subscription: </Text>
|
||||
<Text>Current workspace subscription: </Text>
|
||||
<PlanTag plan={workspace?.plan} />
|
||||
</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 && (
|
||||
<>
|
||||
<Text>
|
||||
|
@ -1,16 +1,25 @@
|
||||
import { Button, ButtonProps, useDisclosure } from '@chakra-ui/react'
|
||||
import { useWorkspace } from 'contexts/WorkspaceContext'
|
||||
import { Plan } from 'db'
|
||||
import React from 'react'
|
||||
import { isNotDefined } from 'utils'
|
||||
import { UpgradeModal } from '../modals/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 { workspace } = useWorkspace()
|
||||
return (
|
||||
<Button colorScheme="blue" {...props} onClick={onOpen}>
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
{...props}
|
||||
isLoading={isNotDefined(workspace)}
|
||||
onClick={onOpen}
|
||||
>
|
||||
{props.children ?? 'Upgrade'}
|
||||
<UpgradeModal isOpen={isOpen} onClose={onClose} type={type} />
|
||||
<UpgradeModal isOpen={isOpen} onClose={onClose} type={type} plan={plan} />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
@ -16,6 +16,7 @@ import {
|
||||
ListProps,
|
||||
Button,
|
||||
HStack,
|
||||
useToast,
|
||||
} from '@chakra-ui/react'
|
||||
import { pay } from 'services/stripe'
|
||||
import { useUser } from 'contexts/UserContext'
|
||||
@ -23,6 +24,7 @@ import { Plan } from 'db'
|
||||
import { useWorkspace } from 'contexts/WorkspaceContext'
|
||||
import { TypebotLogo } from 'assets/logos'
|
||||
import { CheckIcon } from 'assets/icons'
|
||||
import { toTitleCase } from 'utils'
|
||||
|
||||
export enum LimitReached {
|
||||
BRAND = 'Remove branding',
|
||||
@ -43,9 +45,10 @@ export const UpgradeModal = ({
|
||||
plan = Plan.PRO,
|
||||
}: UpgradeModalProps) => {
|
||||
const { user } = useUser()
|
||||
const { workspace } = useWorkspace()
|
||||
const { workspace, refreshWorkspace } = useWorkspace()
|
||||
const [payLoading, setPayLoading] = useState(false)
|
||||
const [currency, setCurrency] = useState<'usd' | 'eur'>('usd')
|
||||
const toast = useToast()
|
||||
|
||||
useEffect(() => {
|
||||
setCurrency(
|
||||
@ -56,12 +59,24 @@ export const UpgradeModal = ({
|
||||
const handlePayClick = async () => {
|
||||
if (!user || !workspace) return
|
||||
setPayLoading(true)
|
||||
await pay({
|
||||
const response = await pay({
|
||||
customerId: workspace.stripeId ?? undefined,
|
||||
user,
|
||||
currency,
|
||||
plan: plan === Plan.TEAM ? 'team' : 'pro',
|
||||
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 (
|
||||
|
@ -32,6 +32,7 @@ const workspaceContext = createContext<{
|
||||
updates: Partial<Workspace>
|
||||
) => Promise<void>
|
||||
deleteCurrentWorkspace: () => Promise<void>
|
||||
refreshWorkspace: (expectedUpdates: Partial<Workspace>) => void
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
//@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 (
|
||||
<workspaceContext.Provider
|
||||
value={{
|
||||
@ -153,6 +165,7 @@ export const WorkspaceContext = ({ children }: { children: ReactNode }) => {
|
||||
createWorkspace,
|
||||
updateWorkspace,
|
||||
deleteCurrentWorkspace,
|
||||
refreshWorkspace,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import React, { useEffect } from 'react'
|
||||
import { AppProps } from 'next/app'
|
||||
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 { useRouterProgressBar } from 'libs/routerProgressBar'
|
||||
import 'assets/styles/routerProgressBar.css'
|
||||
@ -18,12 +18,15 @@ import { actions } from 'libs/kbar'
|
||||
import { enableMocks } from 'mocks'
|
||||
import { SupportBubble } from 'components/shared/SupportBubble'
|
||||
import { WorkspaceContext } from 'contexts/WorkspaceContext'
|
||||
import { toTitleCase } from 'utils'
|
||||
|
||||
const { ToastContainer, toast } = createStandaloneToast(customTheme)
|
||||
|
||||
if (process.env.NEXT_PUBLIC_E2E_TEST === 'enabled') enableMocks()
|
||||
|
||||
const App = ({ Component, pageProps: { session, ...pageProps } }: AppProps) => {
|
||||
useRouterProgressBar()
|
||||
const { query, pathname } = useRouter()
|
||||
const { query, pathname, isReady } = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
pathname.endsWith('/edit')
|
||||
@ -31,30 +34,52 @@ const App = ({ Component, pageProps: { session, ...pageProps } }: AppProps) => {
|
||||
: (document.body.style.overflow = 'auto')
|
||||
}, [pathname])
|
||||
|
||||
useEffect(() => {
|
||||
displayStripeCallbackMessage(query.stripe?.toString(), toast)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isReady])
|
||||
|
||||
const typebotId = query.typebotId?.toString()
|
||||
return (
|
||||
<ChakraProvider theme={customTheme}>
|
||||
<KBarProvider actions={actions}>
|
||||
<SessionProvider session={session}>
|
||||
<UserContext>
|
||||
{typebotId ? (
|
||||
<TypebotContext typebotId={typebotId}>
|
||||
<>
|
||||
<ToastContainer />
|
||||
<ChakraProvider theme={customTheme}>
|
||||
<KBarProvider actions={actions}>
|
||||
<SessionProvider session={session}>
|
||||
<UserContext>
|
||||
{typebotId ? (
|
||||
<TypebotContext typebotId={typebotId}>
|
||||
<WorkspaceContext>
|
||||
<Component />
|
||||
<SupportBubble />
|
||||
</WorkspaceContext>
|
||||
</TypebotContext>
|
||||
) : (
|
||||
<WorkspaceContext>
|
||||
<Component />
|
||||
<Component {...pageProps} />
|
||||
<SupportBubble />
|
||||
</WorkspaceContext>
|
||||
</TypebotContext>
|
||||
) : (
|
||||
<WorkspaceContext>
|
||||
<Component {...pageProps} />
|
||||
<SupportBubble />
|
||||
</WorkspaceContext>
|
||||
)}
|
||||
</UserContext>
|
||||
</SessionProvider>
|
||||
</KBarProvider>
|
||||
</ChakraProvider>
|
||||
)}
|
||||
</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
|
||||
|
@ -10,12 +10,12 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
|
||||
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
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
success_url: `${req.headers.origin}/typebots?stripe=success`,
|
||||
cancel_url: `${req.headers.origin}/typebots?stripe=cancel`,
|
||||
success_url: `${href}?stripe=${plan}`,
|
||||
cancel_url: `${href}?stripe=cancel`,
|
||||
automatic_tax: { enabled: true },
|
||||
allow_promotion_codes: true,
|
||||
customer_email: email,
|
||||
@ -33,7 +33,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
return methodNotAllowed(res)
|
||||
}
|
||||
|
||||
const getPrice = (plan: 'pro' | 'team', currency: 'eur' | 'usd') => {
|
||||
export const getPrice = (plan: 'pro' | 'team', currency: 'eur' | 'usd') => {
|
||||
if (plan === 'team')
|
||||
return currency === 'eur'
|
||||
? process.env.STRIPE_PRICE_TEAM_EUR_ID
|
||||
|
46
apps/builder/pages/api/stripe/update-subscription.ts
Normal file
46
apps/builder/pages/api/stripe/update-subscription.ts
Normal 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)
|
@ -5,8 +5,7 @@ import { Seo } from 'components/Seo'
|
||||
import { FolderContent } from 'components/dashboard/FolderContent'
|
||||
import { TypebotDndContext } from 'contexts/TypebotDndContext'
|
||||
import { useRouter } from 'next/router'
|
||||
import { redeemCoupon } from 'services/coupons'
|
||||
import { Spinner, useToast } from '@chakra-ui/react'
|
||||
import { Spinner } from '@chakra-ui/react'
|
||||
import { pay } from 'services/stripe'
|
||||
import { useUser } from 'contexts/UserContext'
|
||||
import { NextPageContext } from 'next/types'
|
||||
@ -14,13 +13,9 @@ import { useWorkspace } from 'contexts/WorkspaceContext'
|
||||
|
||||
const DashboardPage = () => {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const { query, isReady } = useRouter()
|
||||
const { query } = useRouter()
|
||||
const { user } = useUser()
|
||||
const { workspace } = useWorkspace()
|
||||
const toast = useToast({
|
||||
position: 'top-right',
|
||||
status: 'success',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const subscribePlan = query.subscribePlan as 'pro' | 'team' | undefined
|
||||
@ -37,25 +32,6 @@ const DashboardPage = () => {
|
||||
}
|
||||
}, [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 (
|
||||
<Stack minH="100vh">
|
||||
<Seo title="My typebots" />
|
||||
|
@ -1,25 +1,60 @@
|
||||
import { User } from 'db'
|
||||
import { Plan, User } from 'db'
|
||||
import { loadStripe } from '@stripe/stripe-js/pure'
|
||||
import { isEmpty, sendRequest } from 'utils'
|
||||
import { isDefined, isEmpty, sendRequest } from 'utils'
|
||||
|
||||
type Props = {
|
||||
user: User
|
||||
customerId?: string
|
||||
currency: 'usd' | 'eur'
|
||||
plan: 'pro' | 'team'
|
||||
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))
|
||||
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 }>({
|
||||
method: 'POST',
|
||||
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
|
||||
return stripe?.redirectToCheckout({
|
||||
const stripe = await loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY)
|
||||
await stripe?.redirectToCheckout({
|
||||
sessionId: data?.sessionId,
|
||||
})
|
||||
}
|
||||
|
Reference in New Issue
Block a user