🚸 (billing) Add precheckout form
Collects required company name and email and create the customer before redirecting to checkout
This commit is contained in:
@ -18,6 +18,8 @@ export const createCheckoutSession = authenticatedProcedure
|
|||||||
})
|
})
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
|
email: z.string(),
|
||||||
|
company: z.string(),
|
||||||
workspaceId: z.string(),
|
workspaceId: z.string(),
|
||||||
prefilledEmail: z.string().optional(),
|
prefilledEmail: z.string().optional(),
|
||||||
currency: z.enum(['usd', 'eur']),
|
currency: z.enum(['usd', 'eur']),
|
||||||
@ -35,8 +37,9 @@ export const createCheckoutSession = authenticatedProcedure
|
|||||||
.mutation(
|
.mutation(
|
||||||
async ({
|
async ({
|
||||||
input: {
|
input: {
|
||||||
|
email,
|
||||||
|
company,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
prefilledEmail,
|
|
||||||
currency,
|
currency,
|
||||||
plan,
|
plan,
|
||||||
returnUrl,
|
returnUrl,
|
||||||
@ -69,11 +72,21 @@ export const createCheckoutSession = authenticatedProcedure
|
|||||||
apiVersion: '2022-11-15',
|
apiVersion: '2022-11-15',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const customer = await stripe.customers.create({
|
||||||
|
email,
|
||||||
|
name: company,
|
||||||
|
metadata: { workspaceId },
|
||||||
|
})
|
||||||
|
|
||||||
const session = await stripe.checkout.sessions.create({
|
const session = await stripe.checkout.sessions.create({
|
||||||
success_url: `${returnUrl}?stripe=${plan}&success=true`,
|
success_url: `${returnUrl}?stripe=${plan}&success=true`,
|
||||||
cancel_url: `${returnUrl}?stripe=cancel`,
|
cancel_url: `${returnUrl}?stripe=cancel`,
|
||||||
allow_promotion_codes: true,
|
allow_promotion_codes: true,
|
||||||
customer_email: prefilledEmail,
|
customer: customer.id,
|
||||||
|
customer_update: {
|
||||||
|
address: 'auto',
|
||||||
|
name: 'auto',
|
||||||
|
},
|
||||||
mode: 'subscription',
|
mode: 'subscription',
|
||||||
metadata: { workspaceId, plan, additionalChats, additionalStorage },
|
metadata: { workspaceId, plan, additionalChats, additionalStorage },
|
||||||
currency,
|
currency,
|
||||||
|
@ -134,6 +134,8 @@ test('plan changes should work', async ({ page }) => {
|
|||||||
await page.click('button >> text="4"')
|
await page.click('button >> text="4"')
|
||||||
await expect(page.locator('text="$73"')).toBeVisible()
|
await expect(page.locator('text="$73"')).toBeVisible()
|
||||||
await page.click('button >> text=Upgrade >> nth=0')
|
await page.click('button >> text=Upgrade >> nth=0')
|
||||||
|
await page.getByLabel('Company name').fill('Company LLC')
|
||||||
|
await page.getByRole('button', { name: 'Go to checkout' }).click()
|
||||||
await page.waitForNavigation()
|
await page.waitForNavigation()
|
||||||
expect(page.url()).toContain('https://checkout.stripe.com')
|
expect(page.url()).toContain('https://checkout.stripe.com')
|
||||||
await expect(page.locator('text=$73.00 >> nth=0')).toBeVisible()
|
await expect(page.locator('text=$73.00 >> nth=0')).toBeVisible()
|
||||||
|
@ -7,8 +7,9 @@ import { TextLink } from '@/components/TextLink'
|
|||||||
import { useToast } from '@/hooks/useToast'
|
import { useToast } from '@/hooks/useToast'
|
||||||
import { trpc } from '@/lib/trpc'
|
import { trpc } from '@/lib/trpc'
|
||||||
import { guessIfUserIsEuropean } from 'utils/pricing'
|
import { guessIfUserIsEuropean } from 'utils/pricing'
|
||||||
import { useRouter } from 'next/router'
|
|
||||||
import { Workspace } from 'models'
|
import { Workspace } from 'models'
|
||||||
|
import { PreCheckoutModal, PreCheckoutModalProps } from '../PreCheckoutModal'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
workspace: Pick<Workspace, 'id' | 'stripeId' | 'plan'>
|
workspace: Pick<Workspace, 'id' | 'stripeId' | 'plan'>
|
||||||
@ -16,25 +17,15 @@ type Props = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ChangePlanForm = ({ workspace, onUpgradeSuccess }: Props) => {
|
export const ChangePlanForm = ({ workspace, onUpgradeSuccess }: Props) => {
|
||||||
const router = useRouter()
|
|
||||||
const { user } = useUser()
|
const { user } = useUser()
|
||||||
const { showToast } = useToast()
|
const { showToast } = useToast()
|
||||||
|
const [preCheckoutPlan, setPreCheckoutPlan] =
|
||||||
|
useState<PreCheckoutModalProps['selectedSubscription']>()
|
||||||
|
|
||||||
const { data } = trpc.billing.getSubscription.useQuery({
|
const { data } = trpc.billing.getSubscription.useQuery({
|
||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { mutate: createCheckoutSession, isLoading: isCreatingCheckout } =
|
|
||||||
trpc.billing.createCheckoutSession.useMutation({
|
|
||||||
onError: (error) => {
|
|
||||||
showToast({
|
|
||||||
description: error.message,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
onSuccess: ({ checkoutUrl }) => {
|
|
||||||
router.push(checkoutUrl)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const { mutate: updateSubscription, isLoading: isUpdatingSubscription } =
|
const { mutate: updateSubscription, isLoading: isUpdatingSubscription } =
|
||||||
trpc.billing.updateSubscription.useMutation({
|
trpc.billing.updateSubscription.useMutation({
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
@ -79,15 +70,20 @@ export const ChangePlanForm = ({ workspace, onUpgradeSuccess }: Props) => {
|
|||||||
if (workspace.stripeId) {
|
if (workspace.stripeId) {
|
||||||
updateSubscription(newSubscription)
|
updateSubscription(newSubscription)
|
||||||
} else {
|
} else {
|
||||||
createCheckoutSession({
|
setPreCheckoutPlan(newSubscription)
|
||||||
...newSubscription,
|
|
||||||
returnUrl: window.location.href,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack spacing={6}>
|
<Stack spacing={6}>
|
||||||
|
{!workspace.stripeId && (
|
||||||
|
<PreCheckoutModal
|
||||||
|
selectedSubscription={preCheckoutPlan}
|
||||||
|
existingEmail={user?.email ?? undefined}
|
||||||
|
existingCompany={user?.company ?? undefined}
|
||||||
|
onClose={() => setPreCheckoutPlan(undefined)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<HStack alignItems="stretch" spacing="4" w="full">
|
<HStack alignItems="stretch" spacing="4" w="full">
|
||||||
<StarterPlanContent
|
<StarterPlanContent
|
||||||
initialChatsLimitIndex={
|
initialChatsLimitIndex={
|
||||||
@ -103,7 +99,7 @@ export const ChangePlanForm = ({ workspace, onUpgradeSuccess }: Props) => {
|
|||||||
onPayClick={(props) =>
|
onPayClick={(props) =>
|
||||||
handlePayClick({ ...props, plan: Plan.STARTER })
|
handlePayClick({ ...props, plan: Plan.STARTER })
|
||||||
}
|
}
|
||||||
isLoading={isCreatingCheckout || isUpdatingSubscription}
|
isLoading={isUpdatingSubscription}
|
||||||
currency={data?.subscription.currency}
|
currency={data?.subscription.currency}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@ -119,7 +115,7 @@ export const ChangePlanForm = ({ workspace, onUpgradeSuccess }: Props) => {
|
|||||||
: 0
|
: 0
|
||||||
}
|
}
|
||||||
onPayClick={(props) => handlePayClick({ ...props, plan: Plan.PRO })}
|
onPayClick={(props) => handlePayClick({ ...props, plan: Plan.PRO })}
|
||||||
isLoading={isCreatingCheckout || isUpdatingSubscription}
|
isLoading={isUpdatingSubscription}
|
||||||
currency={data?.subscription.currency}
|
currency={data?.subscription.currency}
|
||||||
/>
|
/>
|
||||||
</HStack>
|
</HStack>
|
||||||
|
@ -0,0 +1,111 @@
|
|||||||
|
import { TextInput } from '@/components/inputs'
|
||||||
|
import { useToast } from '@/hooks/useToast'
|
||||||
|
import { trpc } from '@/lib/trpc'
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Modal,
|
||||||
|
ModalBody,
|
||||||
|
ModalContent,
|
||||||
|
ModalOverlay,
|
||||||
|
Stack,
|
||||||
|
} from '@chakra-ui/react'
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
|
import React, { FormEvent, useState } from 'react'
|
||||||
|
import { isDefined } from 'utils'
|
||||||
|
|
||||||
|
export type PreCheckoutModalProps = {
|
||||||
|
selectedSubscription:
|
||||||
|
| {
|
||||||
|
plan: 'STARTER' | 'PRO'
|
||||||
|
workspaceId: string
|
||||||
|
additionalChats: number
|
||||||
|
additionalStorage: number
|
||||||
|
currency: 'eur' | 'usd'
|
||||||
|
}
|
||||||
|
| undefined
|
||||||
|
existingCompany?: string
|
||||||
|
existingEmail?: string
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PreCheckoutModal = ({
|
||||||
|
selectedSubscription,
|
||||||
|
existingCompany,
|
||||||
|
existingEmail,
|
||||||
|
onClose,
|
||||||
|
}: PreCheckoutModalProps) => {
|
||||||
|
const router = useRouter()
|
||||||
|
const { showToast } = useToast()
|
||||||
|
const { mutate: createCheckoutSession, isLoading: isCreatingCheckout } =
|
||||||
|
trpc.billing.createCheckoutSession.useMutation({
|
||||||
|
onError: (error) => {
|
||||||
|
showToast({
|
||||||
|
description: error.message,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onSuccess: ({ checkoutUrl }) => {
|
||||||
|
router.push(checkoutUrl)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const [customer, setCustomer] = useState({
|
||||||
|
company: existingCompany ?? '',
|
||||||
|
email: existingEmail ?? '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateCustomerCompany = (company: string) => {
|
||||||
|
setCustomer((customer) => ({ ...customer, company }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateCustomerEmail = (email: string) => {
|
||||||
|
setCustomer((customer) => ({ ...customer, email }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const createCustomer = (e: FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!selectedSubscription) return
|
||||||
|
createCheckoutSession({
|
||||||
|
...selectedSubscription,
|
||||||
|
email: customer.email,
|
||||||
|
company: customer.company,
|
||||||
|
returnUrl: window.location.href,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isDefined(selectedSubscription)} onClose={onClose}>
|
||||||
|
<ModalOverlay />
|
||||||
|
<ModalContent>
|
||||||
|
<ModalBody py="8">
|
||||||
|
<Stack as="form" onSubmit={createCustomer} spacing="4">
|
||||||
|
<TextInput
|
||||||
|
isRequired
|
||||||
|
label="Company name"
|
||||||
|
defaultValue={customer.company}
|
||||||
|
onChange={updateCustomerCompany}
|
||||||
|
withVariableButton={false}
|
||||||
|
debounceTimeout={0}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
isRequired
|
||||||
|
type="email"
|
||||||
|
label="Email"
|
||||||
|
defaultValue={customer.email}
|
||||||
|
onChange={updateCustomerEmail}
|
||||||
|
withVariableButton={false}
|
||||||
|
debounceTimeout={0}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
isLoading={isCreatingCheckout}
|
||||||
|
colorScheme="blue"
|
||||||
|
isDisabled={customer.company === '' || customer.email === ''}
|
||||||
|
>
|
||||||
|
Go to checkout
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</ModalBody>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
@ -1,8 +1,11 @@
|
|||||||
import { Seo } from '@/components/Seo'
|
import { Seo } from '@/components/Seo'
|
||||||
import { useUser } from '@/features/account'
|
import { useUser } from '@/features/account'
|
||||||
|
import {
|
||||||
|
PreCheckoutModal,
|
||||||
|
PreCheckoutModalProps,
|
||||||
|
} from '@/features/billing/components/PreCheckoutModal'
|
||||||
import { TypebotDndProvider, FolderContent } from '@/features/folders'
|
import { TypebotDndProvider, FolderContent } from '@/features/folders'
|
||||||
import { useWorkspace } from '@/features/workspace'
|
import { useWorkspace } from '@/features/workspace'
|
||||||
import { trpc } from '@/lib/trpc'
|
|
||||||
import { Stack, VStack, Spinner, Text } from '@chakra-ui/react'
|
import { Stack, VStack, Spinner, Text } from '@chakra-ui/react'
|
||||||
import { Plan } from 'db'
|
import { Plan } from 'db'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
@ -12,15 +15,11 @@ import { DashboardHeader } from './DashboardHeader'
|
|||||||
|
|
||||||
export const DashboardPage = () => {
|
export const DashboardPage = () => {
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const { query, push } = useRouter()
|
const { query } = useRouter()
|
||||||
const { user } = useUser()
|
const { user } = useUser()
|
||||||
const { workspace } = useWorkspace()
|
const { workspace } = useWorkspace()
|
||||||
const { mutate: createCheckoutSession } =
|
const [preCheckoutPlan, setPreCheckoutPlan] =
|
||||||
trpc.billing.createCheckoutSession.useMutation({
|
useState<PreCheckoutModalProps['selectedSubscription']>()
|
||||||
onSuccess: (data) => {
|
|
||||||
push(data.checkoutUrl)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const { subscribePlan, chats, storage } = query as {
|
const { subscribePlan, chats, storage } = query as {
|
||||||
@ -30,22 +29,28 @@ export const DashboardPage = () => {
|
|||||||
}
|
}
|
||||||
if (workspace && subscribePlan && user && workspace.plan === 'FREE') {
|
if (workspace && subscribePlan && user && workspace.plan === 'FREE') {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
createCheckoutSession({
|
setPreCheckoutPlan({
|
||||||
plan: subscribePlan as 'PRO' | 'STARTER',
|
plan: subscribePlan as 'PRO' | 'STARTER',
|
||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
additionalChats: chats ? parseInt(chats) : 0,
|
additionalChats: chats ? parseInt(chats) : 0,
|
||||||
additionalStorage: storage ? parseInt(storage) : 0,
|
additionalStorage: storage ? parseInt(storage) : 0,
|
||||||
returnUrl: window.location.href,
|
|
||||||
currency: guessIfUserIsEuropean() ? 'eur' : 'usd',
|
currency: guessIfUserIsEuropean() ? 'eur' : 'usd',
|
||||||
prefilledEmail: user.email ?? undefined,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [createCheckoutSession, query, user, workspace])
|
}, [query, user, workspace])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack minH="100vh">
|
<Stack minH="100vh">
|
||||||
<Seo title={workspace?.name ?? 'My typebots'} />
|
<Seo title={workspace?.name ?? 'My typebots'} />
|
||||||
<DashboardHeader />
|
<DashboardHeader />
|
||||||
|
{!workspace?.stripeId && (
|
||||||
|
<PreCheckoutModal
|
||||||
|
selectedSubscription={preCheckoutPlan}
|
||||||
|
existingEmail={user?.email ?? undefined}
|
||||||
|
existingCompany={workspace?.name ?? undefined}
|
||||||
|
onClose={() => setPreCheckoutPlan(undefined)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<TypebotDndProvider>
|
<TypebotDndProvider>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<VStack w="full" justifyContent="center" pt="10" spacing={6}>
|
<VStack w="full" justifyContent="center" pt="10" spacing={6}>
|
||||||
|
Reference in New Issue
Block a user