Add user account page
This commit is contained in:
@@ -191,3 +191,12 @@ export const EditIcon = (props: IconProps) => (
|
||||
<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"></path>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const UploadIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<polyline points="16 16 12 12 8 16"></polyline>
|
||||
<line x1="12" y1="12" x2="12" y2="21"></line>
|
||||
<path d="M20.39 18.39A5 5 0 0 0 18 9h-1.26A8 8 0 1 0 3 16.3"></path>
|
||||
<polyline points="16 16 12 12 8 16"></polyline>
|
||||
</Icon>
|
||||
)
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
const withAuth =
|
||||
(WrappedComponent: (props: any) => JSX.Element) =>
|
||||
(props: JSX.IntrinsicAttributes) => {
|
||||
const router = useRouter()
|
||||
const { status } = useSession()
|
||||
|
||||
useEffect(() => {
|
||||
if (!router.isReady) return
|
||||
if (status === 'loading') return
|
||||
if (status === 'unauthenticated') router.replace('/signin')
|
||||
}, [status, router])
|
||||
|
||||
return <WrappedComponent {...props} />
|
||||
}
|
||||
|
||||
export default withAuth
|
||||
24
apps/builder/components/account/AccountHeader.tsx
Normal file
24
apps/builder/components/account/AccountHeader.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Flex } from '@chakra-ui/react'
|
||||
import { TypebotLogo } from 'assets/logos'
|
||||
import { NextChakraLink } from 'components/nextChakra/NextChakraLink'
|
||||
import React from 'react'
|
||||
|
||||
export const AccountHeader = () => (
|
||||
<Flex w="full" borderBottomWidth="1px" justify="center">
|
||||
<Flex
|
||||
justify="space-between"
|
||||
alignItems="center"
|
||||
h="16"
|
||||
maxW="1000px"
|
||||
flex="1"
|
||||
>
|
||||
<NextChakraLink
|
||||
className="w-24"
|
||||
href="/typebots"
|
||||
data-testid="authenticated"
|
||||
>
|
||||
<TypebotLogo w="30px" />
|
||||
</NextChakraLink>
|
||||
</Flex>
|
||||
</Flex>
|
||||
)
|
||||
27
apps/builder/components/account/BillingSection.tsx
Normal file
27
apps/builder/components/account/BillingSection.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Stack, Heading, HStack, Button, Text } from '@chakra-ui/react'
|
||||
import { NextChakraLink } from 'components/nextChakra/NextChakraLink'
|
||||
import { useUser } from 'contexts/UserContext'
|
||||
import React from 'react'
|
||||
import { SubscriptionTag } from './SubscriptionTag'
|
||||
|
||||
export const BillingSection = () => {
|
||||
const { user } = useUser()
|
||||
return (
|
||||
<Stack direction="row" spacing="10" justifyContent={'space-between'}>
|
||||
<Heading as="h2" fontSize="xl">
|
||||
Billing
|
||||
</Heading>
|
||||
<Stack spacing="6" w="400px">
|
||||
<HStack>
|
||||
<Text>Your subscription</Text>
|
||||
<SubscriptionTag plan={user?.plan} />
|
||||
</HStack>
|
||||
{user?.stripeId && (
|
||||
<Button as={NextChakraLink} href="test">
|
||||
Billing portal
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
116
apps/builder/components/account/PersonalInfoForm.tsx
Normal file
116
apps/builder/components/account/PersonalInfoForm.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import {
|
||||
Stack,
|
||||
Heading,
|
||||
HStack,
|
||||
Avatar,
|
||||
Button,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Input,
|
||||
Tooltip,
|
||||
Flex,
|
||||
Text,
|
||||
} from '@chakra-ui/react'
|
||||
import { UploadIcon } from 'assets/icons'
|
||||
import { UploadButton } from 'components/shared/buttons/UploadButton'
|
||||
import { useUser } from 'contexts/UserContext'
|
||||
import React, { ChangeEvent, useState } from 'react'
|
||||
import { uploadFile } from 'services/utils'
|
||||
|
||||
export const PersonalInfoForm = () => {
|
||||
const {
|
||||
user,
|
||||
updateUser,
|
||||
saveUser,
|
||||
hasUnsavedChanges,
|
||||
isSaving,
|
||||
isOAuthProvider,
|
||||
} = useUser()
|
||||
const [reloadParam, setReloadParam] = useState('')
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
|
||||
const handleFileChange = async (file: File) => {
|
||||
setIsUploading(true)
|
||||
const { url } = await uploadFile(file, `${user?.id}/avatar`)
|
||||
setReloadParam(Date.now().toString())
|
||||
updateUser({ image: url })
|
||||
setIsUploading(false)
|
||||
}
|
||||
|
||||
const handleNameChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
updateUser({ name: e.target.value })
|
||||
}
|
||||
|
||||
const handleEmailChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
updateUser({ email: e.target.value })
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack direction="row" spacing="10" justifyContent={'space-between'}>
|
||||
<Heading as="h2" fontSize="xl">
|
||||
Personal info
|
||||
</Heading>
|
||||
<Stack spacing="6" w="400px">
|
||||
<HStack spacing={6}>
|
||||
<Avatar
|
||||
size="lg"
|
||||
src={user?.image ? `${user.image}?${reloadParam}` : undefined}
|
||||
name={user?.name ?? undefined}
|
||||
/>
|
||||
<Stack>
|
||||
<UploadButton
|
||||
size="sm"
|
||||
leftIcon={<UploadIcon />}
|
||||
isLoading={isUploading}
|
||||
onUploadChange={handleFileChange}
|
||||
>
|
||||
Change photo
|
||||
</UploadButton>
|
||||
<Text color="gray.500" fontSize="sm">
|
||||
.jpg or.png, max 1MB
|
||||
</Text>
|
||||
</Stack>
|
||||
</HStack>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel htmlFor="name">Name</FormLabel>
|
||||
<Input
|
||||
id="name"
|
||||
value={user?.name ?? ''}
|
||||
onChange={handleNameChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<Tooltip
|
||||
label="Can't update the email because it is linked to an OAuth service"
|
||||
placement="left"
|
||||
hasArrow
|
||||
isDisabled={!isOAuthProvider}
|
||||
>
|
||||
<FormControl>
|
||||
<FormLabel
|
||||
htmlFor="email"
|
||||
color={isOAuthProvider ? 'gray.500' : 'current'}
|
||||
>
|
||||
Email address
|
||||
</FormLabel>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
isDisabled={isOAuthProvider}
|
||||
value={user?.email ?? ''}
|
||||
onChange={handleEmailChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</Tooltip>
|
||||
|
||||
{hasUnsavedChanges && (
|
||||
<Flex justifyContent="flex-end">
|
||||
<Button colorScheme="blue" onClick={saveUser} isLoading={isSaving}>
|
||||
Save
|
||||
</Button>
|
||||
</Flex>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
22
apps/builder/components/account/SubscriptionTag.tsx
Normal file
22
apps/builder/components/account/SubscriptionTag.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Tag } from '@chakra-ui/react'
|
||||
import { Plan } from 'db'
|
||||
|
||||
export const SubscriptionTag = ({ plan }: { plan?: Plan }) => {
|
||||
switch (plan) {
|
||||
case Plan.FREE: {
|
||||
return <Tag>Free plan</Tag>
|
||||
}
|
||||
case Plan.LIFETIME: {
|
||||
return <Tag colorScheme="yellow">Lifetime plan</Tag>
|
||||
}
|
||||
case Plan.OFFERED: {
|
||||
return <Tag>Offered</Tag>
|
||||
}
|
||||
case Plan.PRO: {
|
||||
return <Tag colorScheme="blue">Pro plan</Tag>
|
||||
}
|
||||
default: {
|
||||
return <Tag>Free plan</Tag>
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import { Block, StartBlock } from 'bot-engine'
|
||||
import { useGraph } from 'contexts/GraphContext'
|
||||
import { useDnd } from 'contexts/DndContext'
|
||||
import { StepsList } from './StepsList'
|
||||
import { isNotDefined } from 'services/utils'
|
||||
import { isDefined } from 'services/utils'
|
||||
import { useTypebot } from 'contexts/TypebotContext'
|
||||
import { ContextMenu } from 'components/shared/ContextMenu'
|
||||
import { BlockNodeContextMenu } from './BlockNodeContextMenu'
|
||||
@@ -34,7 +34,7 @@ export const BlockNode = ({ block }: { block: Block | StartBlock }) => {
|
||||
useEffect(() => {
|
||||
setIsConnecting(
|
||||
connectingIds?.target?.blockId === block.id &&
|
||||
isNotDefined(connectingIds.target?.stepId)
|
||||
!isDefined(connectingIds.target?.stepId)
|
||||
)
|
||||
}, [block.id, connectingIds])
|
||||
|
||||
|
||||
@@ -12,13 +12,13 @@ import {
|
||||
Skeleton,
|
||||
} from '@chakra-ui/react'
|
||||
import { TypebotLogo } from 'assets/logos'
|
||||
import { useUser } from 'services/user'
|
||||
import { NextChakraLink } from 'components/nextChakra/NextChakraLink'
|
||||
import { LogOutIcon, SettingsIcon } from 'assets/icons'
|
||||
import { signOut } from 'next-auth/react'
|
||||
import { useUser } from 'contexts/UserContext'
|
||||
|
||||
export const DashboardHeader = () => {
|
||||
const user = useUser()
|
||||
const { user } = useUser()
|
||||
|
||||
const handleLogOut = () => {
|
||||
signOut()
|
||||
|
||||
34
apps/builder/components/shared/buttons/UploadButton.tsx
Normal file
34
apps/builder/components/shared/buttons/UploadButton.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Button, ButtonProps, chakra } from '@chakra-ui/react'
|
||||
import React, { ChangeEvent } from 'react'
|
||||
|
||||
type UploadButtonProps = { onUploadChange: (file: File) => void } & ButtonProps
|
||||
|
||||
export const UploadButton = ({
|
||||
onUploadChange,
|
||||
...props
|
||||
}: UploadButtonProps) => {
|
||||
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
if (!e.target?.files) return
|
||||
onUploadChange(e.target.files[0])
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<chakra.input
|
||||
type="file"
|
||||
id="file-input"
|
||||
display="none"
|
||||
onChange={handleInputChange}
|
||||
accept=".jpg, .jpeg, .png"
|
||||
/>
|
||||
<Button
|
||||
as="label"
|
||||
size="sm"
|
||||
htmlFor="file-input"
|
||||
cursor="pointer"
|
||||
{...props}
|
||||
>
|
||||
{props.children}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
100
apps/builder/contexts/UserContext.tsx
Normal file
100
apps/builder/contexts/UserContext.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { User } from 'db'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { useRouter } from 'next/router'
|
||||
import {
|
||||
createContext,
|
||||
ReactNode,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { isDefined } from 'services/utils'
|
||||
import { updateUser as updateUserInDb } from 'services/user'
|
||||
import { useToast } from '@chakra-ui/react'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
|
||||
const userContext = createContext<{
|
||||
user?: User
|
||||
isLoading: boolean
|
||||
isSaving: boolean
|
||||
hasUnsavedChanges: boolean
|
||||
isOAuthProvider: boolean
|
||||
updateUser: (newUser: Partial<User>) => void
|
||||
saveUser: () => void
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
//@ts-ignore
|
||||
}>({})
|
||||
|
||||
export const UserContext = ({ children }: { children: ReactNode }) => {
|
||||
const router = useRouter()
|
||||
const { data: session, status } = useSession()
|
||||
|
||||
const [user, setUser] = useState<User>()
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const isOAuthProvider = useMemo(
|
||||
() => (session?.providerType as boolean | undefined) ?? false,
|
||||
[session?.providerType]
|
||||
)
|
||||
|
||||
const hasUnsavedChanges = useMemo(
|
||||
() => !deepEqual(session?.user, user),
|
||||
[session?.user, user]
|
||||
)
|
||||
|
||||
const toast = useToast({
|
||||
position: 'top-right',
|
||||
status: 'error',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (isDefined(user) || !isDefined(session)) return
|
||||
|
||||
setUser(session.user as User)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [session])
|
||||
|
||||
useEffect(() => {
|
||||
if (!router.isReady) return
|
||||
if (status === 'loading') return
|
||||
if (status === 'unauthenticated') router.replace('/signin')
|
||||
}, [status, router])
|
||||
|
||||
const updateUser = (newUser: Partial<User>) => {
|
||||
if (!isDefined(user)) return
|
||||
setUser({ ...user, ...newUser })
|
||||
}
|
||||
|
||||
const saveUser = async () => {
|
||||
if (!isDefined(user)) return
|
||||
setIsSaving(true)
|
||||
const { error } = await updateUserInDb(user.id, user)
|
||||
if (error) toast({ title: error.name, description: error.message })
|
||||
await fetch('/api/auth/session?update')
|
||||
reloadSession()
|
||||
setIsSaving(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<userContext.Provider
|
||||
value={{
|
||||
user,
|
||||
updateUser,
|
||||
saveUser,
|
||||
isSaving,
|
||||
isLoading: status === 'loading',
|
||||
hasUnsavedChanges,
|
||||
isOAuthProvider,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</userContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const reloadSession = () => {
|
||||
const event = new Event('visibilitychange')
|
||||
document.dispatchEvent(event)
|
||||
}
|
||||
|
||||
export const useUser = () => useContext(userContext)
|
||||
34
apps/builder/layouts/account/AccountContent.tsx
Normal file
34
apps/builder/layouts/account/AccountContent.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Flex, Stack, Heading, Divider, Button } from '@chakra-ui/react'
|
||||
import { ChevronLeftIcon } from 'assets/icons'
|
||||
import { NextChakraLink } from 'components/nextChakra/NextChakraLink'
|
||||
import React from 'react'
|
||||
import { PersonalInfoForm } from 'components/account/PersonalInfoForm'
|
||||
import { BillingSection } from 'components/account/BillingSection'
|
||||
|
||||
export const AccountContent = () => {
|
||||
return (
|
||||
<Flex h="full" w="full" justifyContent="center" align="flex-start">
|
||||
<Stack maxW="600px" w="full" pt="4" spacing={10}>
|
||||
<Flex>
|
||||
<Button
|
||||
as={NextChakraLink}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<ChevronLeftIcon />}
|
||||
href="/typebots"
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
<Heading as="h1" fontSize="3xl">
|
||||
Account Settings
|
||||
</Heading>
|
||||
<Divider />
|
||||
<PersonalInfoForm />
|
||||
<Divider />
|
||||
<BillingSection />
|
||||
</Stack>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
@@ -24,6 +24,7 @@
|
||||
"@udecode/plate-link": "^9.0.0",
|
||||
"@udecode/plate-ui-link": "^9.0.0",
|
||||
"@udecode/plate-ui-toolbar": "^9.0.0",
|
||||
"aws-sdk": "^2.1048.0",
|
||||
"bot-engine": "*",
|
||||
"db": "*",
|
||||
"fast-equals": "^2.0.4",
|
||||
@@ -31,6 +32,7 @@
|
||||
"framer-motion": "^4",
|
||||
"htmlparser2": "^7.2.0",
|
||||
"kbar": "^0.1.0-beta.24",
|
||||
"micro-cors": "^0.1.1",
|
||||
"next": "^12.0.7",
|
||||
"next-auth": "beta",
|
||||
"nodemailer": "^6.7.2",
|
||||
@@ -44,6 +46,7 @@
|
||||
"slate-history": "^0.66.0",
|
||||
"slate-hyperscript": "^0.67.0",
|
||||
"slate-react": "^0.72.1",
|
||||
"stripe": "^8.195.0",
|
||||
"styled-components": "^5.3.3",
|
||||
"svg-round-corners": "^0.3.0",
|
||||
"swr": "^1.1.1",
|
||||
@@ -51,6 +54,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/cypress": "^8.0.2",
|
||||
"@types/micro-cors": "^0.1.2",
|
||||
"@types/node": "^16.11.9",
|
||||
"@types/nprogress": "^0.2.0",
|
||||
"@types/react": "^17.0.37",
|
||||
|
||||
18
apps/builder/pages/account.tsx
Normal file
18
apps/builder/pages/account.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Stack } from '@chakra-ui/react'
|
||||
import { AccountHeader } from 'components/account/AccountHeader'
|
||||
import { Seo } from 'components/Seo'
|
||||
import { UserContext } from 'contexts/UserContext'
|
||||
import { AccountContent } from 'layouts/account/AccountContent'
|
||||
|
||||
const AccountSubscriptionPage = () => {
|
||||
return (
|
||||
<UserContext>
|
||||
<Seo title="My account" />{' '}
|
||||
<Stack>
|
||||
<AccountHeader />
|
||||
<AccountContent />
|
||||
</Stack>
|
||||
</UserContext>
|
||||
)
|
||||
}
|
||||
export default AccountSubscriptionPage
|
||||
@@ -1,4 +1,4 @@
|
||||
import NextAuth from 'next-auth'
|
||||
import NextAuth, { NextAuthOptions } from 'next-auth'
|
||||
import { PrismaAdapter } from '@next-auth/prisma-adapter'
|
||||
import EmailProvider from 'next-auth/providers/email'
|
||||
import GitHubProvider from 'next-auth/providers/github'
|
||||
@@ -8,6 +8,7 @@ import CredentialsProvider from 'next-auth/providers/credentials'
|
||||
import prisma from 'libs/prisma'
|
||||
import { Provider } from 'next-auth/providers'
|
||||
import { User } from 'db'
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
|
||||
const providers: Provider[] = [
|
||||
EmailProvider({
|
||||
@@ -67,7 +68,7 @@ if (process.env.NODE_ENV !== 'production')
|
||||
})
|
||||
)
|
||||
|
||||
export default NextAuth({
|
||||
const createOptions = (req: NextApiRequest): NextAuthOptions => ({
|
||||
adapter: PrismaAdapter(prisma),
|
||||
secret: process.env.SECRET,
|
||||
providers,
|
||||
@@ -75,13 +76,25 @@ export default NextAuth({
|
||||
strategy: process.env.NODE_ENV === 'production' ? 'database' : 'jwt',
|
||||
},
|
||||
callbacks: {
|
||||
jwt: async ({ token, user }) => {
|
||||
user && (token.user = user)
|
||||
jwt: async ({ token, user, account }) => {
|
||||
if (req.url === '/api/auth/session?update' && token.user) {
|
||||
token.user = await prisma.user.findUnique({
|
||||
where: { id: (token.user as User).id },
|
||||
})
|
||||
} else if (user) {
|
||||
token.user = user
|
||||
}
|
||||
account?.type && (token.providerType = account?.type)
|
||||
return token
|
||||
},
|
||||
session: async ({ session, token, user }) => {
|
||||
token?.user ? (session.user = token.user as User) : (session.user = user)
|
||||
return session
|
||||
return { ...session, providerType: token.providerType }
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const handler = (req: NextApiRequest, res: NextApiResponse) => {
|
||||
NextAuth(req, res, createOptions(req))
|
||||
}
|
||||
export default handler
|
||||
|
||||
46
apps/builder/pages/api/storage/upload-url.ts
Normal file
46
apps/builder/pages/api/storage/upload-url.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import aws from 'aws-sdk'
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { getSession } from 'next-auth/react'
|
||||
import { methodNotAllowed } from 'services/api/utils'
|
||||
|
||||
const maxUploadFileSize = 10485760 // 10 MB
|
||||
const handler = async (
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
): Promise<void> => {
|
||||
try {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*')
|
||||
if (req.method === 'GET') {
|
||||
const session = await getSession({ req })
|
||||
if (!session) {
|
||||
res.status(401)
|
||||
return
|
||||
}
|
||||
aws.config.update({
|
||||
accessKeyId: process.env.S3_UPLOAD_KEY,
|
||||
secretAccessKey: process.env.S3_UPLOAD_SECRET,
|
||||
region: process.env.S3_UPLOAD_REGION,
|
||||
signatureVersion: 'v4',
|
||||
})
|
||||
|
||||
const s3 = new aws.S3()
|
||||
const post = s3.createPresignedPost({
|
||||
Bucket: process.env.S3_UPLOAD_BUCKET,
|
||||
Fields: {
|
||||
ACL: 'public-read',
|
||||
key: req.query.key,
|
||||
'Content-Type': req.query.fileType,
|
||||
},
|
||||
Expires: 120, // seconds
|
||||
Conditions: [['content-length-range', 0, maxUploadFileSize]],
|
||||
})
|
||||
|
||||
return res.status(200).json(post)
|
||||
}
|
||||
return methodNotAllowed(res)
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
}
|
||||
}
|
||||
|
||||
export default handler
|
||||
35
apps/builder/pages/api/stripe/checkout.ts
Normal file
35
apps/builder/pages/api/stripe/checkout.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { methodNotAllowed } from 'services/api/utils'
|
||||
import Stripe from 'stripe'
|
||||
|
||||
const usdPriceIdTest = 'price_1Jc4TQKexUFvKTWyGvsH4Ff5'
|
||||
const createCheckoutSession = async (
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) => {
|
||||
if (req.method === 'POST') {
|
||||
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 { email } = 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`,
|
||||
automatic_tax: { enabled: true },
|
||||
allow_promotion_codes: true,
|
||||
customer_email: email,
|
||||
line_items: [
|
||||
{
|
||||
price: usdPriceIdTest,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
})
|
||||
res.status(201).json(session)
|
||||
}
|
||||
return methodNotAllowed(res)
|
||||
}
|
||||
|
||||
export default createCheckoutSession
|
||||
73
apps/builder/pages/api/stripe/webhook.ts
Normal file
73
apps/builder/pages/api/stripe/webhook.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { methodNotAllowed } from 'services/api/utils'
|
||||
import Stripe from 'stripe'
|
||||
import Cors from 'micro-cors'
|
||||
import { buffer } from 'micro'
|
||||
import prisma from 'libs/prisma'
|
||||
import { Plan } from 'db'
|
||||
|
||||
if (!process.env.STRIPE_SECRET_KEY || !process.env.STRIPE_WEBHOOK_SECRET)
|
||||
throw new Error('STRIPE_SECRET_KEY or STRIPE_WEBHOOK_SECRET missing')
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
|
||||
apiVersion: '2020-08-27',
|
||||
})
|
||||
|
||||
const cors = Cors({
|
||||
allowMethods: ['POST', 'HEAD'],
|
||||
})
|
||||
|
||||
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET as string
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
}
|
||||
|
||||
const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method === 'POST') {
|
||||
const buf = await buffer(req)
|
||||
const sig = req.headers['stripe-signature']
|
||||
|
||||
if (!sig) return res.status(400).send(`stripe-signature is missing`)
|
||||
try {
|
||||
const event = stripe.webhooks.constructEvent(
|
||||
buf.toString(),
|
||||
sig.toString(),
|
||||
webhookSecret
|
||||
)
|
||||
switch (event.type) {
|
||||
case 'checkout.session.completed': {
|
||||
const session = event.data.object as Stripe.Checkout.Session
|
||||
const { customer_email } = session
|
||||
if (!customer_email)
|
||||
return res.status(500).send(`customer_email not found`)
|
||||
await prisma.user.update({
|
||||
where: { email: customer_email },
|
||||
data: { plan: Plan.PRO, stripeId: session.customer as string },
|
||||
})
|
||||
}
|
||||
case 'customer.subscription.deleted': {
|
||||
const subscription = event.data.object as Stripe.Subscription
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
stripeId: subscription.customer as string,
|
||||
},
|
||||
data: {
|
||||
plan: Plan.FREE,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
console.error(err)
|
||||
return res.status(400).send(`Webhook Error: ${err.message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
return methodNotAllowed(res)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export default cors(webhookHandler as any)
|
||||
24
apps/builder/pages/api/users/[id].ts
Normal file
24
apps/builder/pages/api/users/[id].ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import prisma from 'libs/prisma'
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { getSession } from 'next-auth/react'
|
||||
import { methodNotAllowed } from 'services/api/utils'
|
||||
|
||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const session = await getSession({ req })
|
||||
|
||||
if (!session?.user)
|
||||
return res.status(401).json({ message: 'Not authenticated' })
|
||||
|
||||
const id = req.query.id.toString()
|
||||
if (req.method === 'PUT') {
|
||||
const data = JSON.parse(req.body)
|
||||
const typebots = await prisma.user.update({
|
||||
where: { id },
|
||||
data,
|
||||
})
|
||||
return res.send({ typebots })
|
||||
}
|
||||
return methodNotAllowed(res)
|
||||
}
|
||||
|
||||
export default handler
|
||||
@@ -1,18 +1,20 @@
|
||||
import withAuth from 'components/HOC/withUser'
|
||||
import React from 'react'
|
||||
import { Stack } from '@chakra-ui/layout'
|
||||
import { DashboardHeader } from 'components/dashboard/DashboardHeader'
|
||||
import { Seo } from 'components/Seo'
|
||||
import { FolderContent } from 'components/dashboard/FolderContent'
|
||||
import { UserContext } from 'contexts/UserContext'
|
||||
|
||||
const DashboardPage = () => {
|
||||
return (
|
||||
<Stack>
|
||||
<UserContext>
|
||||
<Seo title="My typebots" />
|
||||
<DashboardHeader />
|
||||
<FolderContent folder={null} />
|
||||
</Stack>
|
||||
<Stack>
|
||||
<DashboardHeader />
|
||||
<FolderContent folder={null} />
|
||||
</Stack>
|
||||
</UserContext>
|
||||
)
|
||||
}
|
||||
|
||||
export default withAuth(DashboardPage)
|
||||
export default DashboardPage
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Flex } from '@chakra-ui/layout'
|
||||
import { Board } from 'components/board/Board'
|
||||
import withAuth from 'components/HOC/withUser'
|
||||
import { Seo } from 'components/Seo'
|
||||
import { TypebotHeader } from 'components/shared/TypebotHeader'
|
||||
import { EditorContext } from 'contexts/EditorContext'
|
||||
@@ -11,25 +10,28 @@ import { KBarProvider } from 'kbar'
|
||||
import React from 'react'
|
||||
import { actions } from 'libs/kbar'
|
||||
import { KBar } from 'components/shared/KBar'
|
||||
import { UserContext } from 'contexts/UserContext'
|
||||
|
||||
const TypebotEditPage = () => {
|
||||
const { query } = useRouter()
|
||||
return (
|
||||
<TypebotContext typebotId={query.id?.toString()}>
|
||||
<Seo title="Editor" />
|
||||
<EditorContext>
|
||||
<KBarProvider actions={actions}>
|
||||
<KBar />
|
||||
<Flex overflow="hidden" h="100vh" flexDir="column">
|
||||
<TypebotHeader />
|
||||
<GraphProvider>
|
||||
<Board />
|
||||
</GraphProvider>
|
||||
</Flex>
|
||||
</KBarProvider>
|
||||
</EditorContext>
|
||||
</TypebotContext>
|
||||
<UserContext>
|
||||
<TypebotContext typebotId={query.id?.toString()}>
|
||||
<Seo title="Editor" />
|
||||
<EditorContext>
|
||||
<KBarProvider actions={actions}>
|
||||
<KBar />
|
||||
<Flex overflow="hidden" h="100vh" flexDir="column">
|
||||
<TypebotHeader />
|
||||
<GraphProvider>
|
||||
<Board />
|
||||
</GraphProvider>
|
||||
</Flex>
|
||||
</KBarProvider>
|
||||
</EditorContext>
|
||||
</TypebotContext>
|
||||
</UserContext>
|
||||
)
|
||||
}
|
||||
|
||||
export default withAuth(TypebotEditPage)
|
||||
export default TypebotEditPage
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
import { Flex } from '@chakra-ui/layout'
|
||||
import withAuth from 'components/HOC/withUser'
|
||||
import { ResultsContent } from 'layouts/results/ResultsContent'
|
||||
import { Seo } from 'components/Seo'
|
||||
import { TypebotHeader } from 'components/shared/TypebotHeader'
|
||||
import { TypebotContext } from 'contexts/TypebotContext'
|
||||
import { useRouter } from 'next/router'
|
||||
import React from 'react'
|
||||
import { UserContext } from 'contexts/UserContext'
|
||||
|
||||
const ResultsPage = () => {
|
||||
const { query } = useRouter()
|
||||
return (
|
||||
<TypebotContext typebotId={query.id?.toString()}>
|
||||
<Seo title="Share" />
|
||||
<Flex overflow="hidden" h="100vh" flexDir="column">
|
||||
<TypebotHeader />
|
||||
<ResultsContent />
|
||||
</Flex>
|
||||
</TypebotContext>
|
||||
<UserContext>
|
||||
<TypebotContext typebotId={query.id?.toString()}>
|
||||
<Seo title="Share" />
|
||||
<Flex overflow="hidden" h="100vh" flexDir="column">
|
||||
<TypebotHeader />
|
||||
<ResultsContent />
|
||||
</Flex>
|
||||
</TypebotContext>
|
||||
</UserContext>
|
||||
)
|
||||
}
|
||||
|
||||
export default withAuth(ResultsPage)
|
||||
export default ResultsPage
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
import { Flex } from '@chakra-ui/layout'
|
||||
import withAuth from 'components/HOC/withUser'
|
||||
import { ResultsContent } from 'layouts/results/ResultsContent'
|
||||
import { Seo } from 'components/Seo'
|
||||
import { TypebotHeader } from 'components/shared/TypebotHeader'
|
||||
import { TypebotContext } from 'contexts/TypebotContext'
|
||||
import { useRouter } from 'next/router'
|
||||
import React from 'react'
|
||||
import { UserContext } from 'contexts/UserContext'
|
||||
|
||||
const AnalyticsPage = () => {
|
||||
const { query } = useRouter()
|
||||
return (
|
||||
<TypebotContext typebotId={query.id?.toString()}>
|
||||
<Seo title="Analytics" />
|
||||
<Flex overflow="hidden" h="100vh" flexDir="column">
|
||||
<TypebotHeader />
|
||||
<ResultsContent />
|
||||
</Flex>
|
||||
</TypebotContext>
|
||||
<UserContext>
|
||||
<TypebotContext typebotId={query.id?.toString()}>
|
||||
<Seo title="Analytics" />
|
||||
<Flex overflow="hidden" h="100vh" flexDir="column">
|
||||
<TypebotHeader />
|
||||
<ResultsContent />
|
||||
</Flex>
|
||||
</TypebotContext>
|
||||
</UserContext>
|
||||
)
|
||||
}
|
||||
|
||||
export default withAuth(AnalyticsPage)
|
||||
export default AnalyticsPage
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
import { Flex } from '@chakra-ui/layout'
|
||||
import withAuth from 'components/HOC/withUser'
|
||||
import { Seo } from 'components/Seo'
|
||||
import { SettingsContent } from 'components/settings/SettingsContent'
|
||||
import { TypebotHeader } from 'components/shared/TypebotHeader'
|
||||
import { TypebotContext } from 'contexts/TypebotContext'
|
||||
import { UserContext } from 'contexts/UserContext'
|
||||
import { useRouter } from 'next/router'
|
||||
import React from 'react'
|
||||
|
||||
const SettingsPage = () => {
|
||||
const { query } = useRouter()
|
||||
return (
|
||||
<TypebotContext typebotId={query.id?.toString()}>
|
||||
<Seo title="Settings" />
|
||||
<Flex overflow="hidden" h="100vh" flexDir="column">
|
||||
<TypebotHeader />
|
||||
<SettingsContent />
|
||||
</Flex>
|
||||
</TypebotContext>
|
||||
<UserContext>
|
||||
<TypebotContext typebotId={query.id?.toString()}>
|
||||
<Seo title="Settings" />
|
||||
<Flex overflow="hidden" h="100vh" flexDir="column">
|
||||
<TypebotHeader />
|
||||
<SettingsContent />
|
||||
</Flex>
|
||||
</TypebotContext>
|
||||
</UserContext>
|
||||
)
|
||||
}
|
||||
|
||||
export default withAuth(SettingsPage)
|
||||
export default SettingsPage
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
import { Flex } from '@chakra-ui/layout'
|
||||
import withAuth from 'components/HOC/withUser'
|
||||
import { Seo } from 'components/Seo'
|
||||
import { ShareContent } from 'components/share/ShareContent'
|
||||
import { TypebotHeader } from 'components/shared/TypebotHeader'
|
||||
import { TypebotContext } from 'contexts/TypebotContext'
|
||||
import { UserContext } from 'contexts/UserContext'
|
||||
import { useRouter } from 'next/router'
|
||||
import React from 'react'
|
||||
|
||||
const SharePage = () => {
|
||||
const { query } = useRouter()
|
||||
return (
|
||||
<TypebotContext typebotId={query.id?.toString()}>
|
||||
<Seo title="Share" />
|
||||
<Flex overflow="hidden" h="100vh" flexDir="column">
|
||||
<TypebotHeader />
|
||||
<ShareContent />
|
||||
</Flex>
|
||||
</TypebotContext>
|
||||
<UserContext>
|
||||
<TypebotContext typebotId={query.id?.toString()}>
|
||||
<Seo title="Share" />
|
||||
<Flex overflow="hidden" h="100vh" flexDir="column">
|
||||
<TypebotHeader />
|
||||
<ShareContent />
|
||||
</Flex>
|
||||
</TypebotContext>
|
||||
</UserContext>
|
||||
)
|
||||
}
|
||||
|
||||
export default withAuth(SharePage)
|
||||
export default SharePage
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
import { Flex } from '@chakra-ui/layout'
|
||||
import withAuth from 'components/HOC/withUser'
|
||||
import { Seo } from 'components/Seo'
|
||||
import { TypebotHeader } from 'components/shared/TypebotHeader'
|
||||
import { ThemeContent } from 'components/theme/ThemeContent'
|
||||
import { TypebotContext } from 'contexts/TypebotContext'
|
||||
import { UserContext } from 'contexts/UserContext'
|
||||
import { useRouter } from 'next/router'
|
||||
import React from 'react'
|
||||
|
||||
const ThemePage = () => {
|
||||
const { query } = useRouter()
|
||||
return (
|
||||
<TypebotContext typebotId={query.id?.toString()}>
|
||||
<Seo title="Theme" />
|
||||
<Flex overflow="hidden" h="100vh" flexDir="column">
|
||||
<TypebotHeader />
|
||||
<ThemeContent />
|
||||
</Flex>
|
||||
</TypebotContext>
|
||||
<UserContext>
|
||||
<TypebotContext typebotId={query.id?.toString()}>
|
||||
<Seo title="Theme" />
|
||||
<Flex overflow="hidden" h="100vh" flexDir="column">
|
||||
<TypebotHeader />
|
||||
<ThemeContent />
|
||||
</Flex>
|
||||
</TypebotContext>
|
||||
</UserContext>
|
||||
)
|
||||
}
|
||||
|
||||
export default withAuth(ThemePage)
|
||||
export default ThemePage
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Button, Stack, useToast } from '@chakra-ui/react'
|
||||
import { useUser } from 'services/user'
|
||||
import { useRouter } from 'next/router'
|
||||
import { Seo } from 'components/Seo'
|
||||
import { DashboardHeader } from 'components/dashboard/DashboardHeader'
|
||||
import { createTypebot } from 'services/typebots'
|
||||
import withAuth from 'components/HOC/withUser'
|
||||
import { UserContext, useUser } from 'contexts/UserContext'
|
||||
|
||||
const TemplatesPage = () => {
|
||||
const user = useUser()
|
||||
const { user } = useUser()
|
||||
const router = useRouter()
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
@@ -31,14 +30,20 @@ const TemplatesPage = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<UserContext>
|
||||
<Seo title="Templates" />
|
||||
<DashboardHeader />
|
||||
<Button ml={4} onClick={() => handleCreateSubmit()} isLoading={isLoading}>
|
||||
Start from scratch
|
||||
</Button>
|
||||
</Stack>
|
||||
<Stack>
|
||||
<DashboardHeader />
|
||||
<Button
|
||||
ml={4}
|
||||
onClick={() => handleCreateSubmit()}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
Start from scratch
|
||||
</Button>
|
||||
</Stack>
|
||||
</UserContext>
|
||||
)
|
||||
}
|
||||
|
||||
export default withAuth(TemplatesPage)
|
||||
export default TemplatesPage
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import withAuth from 'components/HOC/withUser'
|
||||
import React from 'react'
|
||||
import { Flex, Stack } from '@chakra-ui/layout'
|
||||
import { DashboardHeader } from 'components/dashboard/DashboardHeader'
|
||||
@@ -7,6 +6,7 @@ import { FolderContent } from 'components/dashboard/FolderContent'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useFolderContent } from 'services/folders'
|
||||
import { Spinner, useToast } from '@chakra-ui/react'
|
||||
import { UserContext } from 'contexts/UserContext'
|
||||
|
||||
const FolderPage = () => {
|
||||
const router = useRouter()
|
||||
@@ -27,18 +27,20 @@ const FolderPage = () => {
|
||||
})
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<UserContext>
|
||||
<Seo title="My typebots" />
|
||||
<DashboardHeader />
|
||||
{!folder ? (
|
||||
<Flex flex="1">
|
||||
<Spinner mx="auto" />
|
||||
</Flex>
|
||||
) : (
|
||||
<FolderContent folder={folder} />
|
||||
)}
|
||||
</Stack>
|
||||
<Stack>
|
||||
<DashboardHeader />
|
||||
{!folder ? (
|
||||
<Flex flex="1">
|
||||
<Spinner mx="auto" />
|
||||
</Flex>
|
||||
) : (
|
||||
<FolderContent folder={folder} />
|
||||
)}
|
||||
</Stack>
|
||||
</UserContext>
|
||||
)
|
||||
}
|
||||
|
||||
export default withAuth(FolderPage)
|
||||
export default FolderPage
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { User } from 'db'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { sendRequest } from './utils'
|
||||
|
||||
export const useUser = (): User | undefined => {
|
||||
const { data } = useSession()
|
||||
return data?.user as User | undefined
|
||||
}
|
||||
export const updateUser = async (id: string, user: User) =>
|
||||
sendRequest({
|
||||
url: `/api/users/${id}`,
|
||||
method: 'PUT',
|
||||
body: user,
|
||||
})
|
||||
|
||||
@@ -43,10 +43,6 @@ export const isDefined = <T>(value: T | undefined | null): value is T => {
|
||||
return <T>value !== undefined && <T>value !== null
|
||||
}
|
||||
|
||||
export const isNotDefined = <T>(value: T | undefined | null): value is T => {
|
||||
return <T>value === undefined || <T>value === null
|
||||
}
|
||||
|
||||
export const preventUserFromRefreshing = (e: BeforeUnloadEvent) => {
|
||||
e.preventDefault()
|
||||
e.returnValue = ''
|
||||
@@ -91,3 +87,26 @@ export const omit: Omit = (obj, ...keys) => {
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
export const uploadFile = async (file: File, key: string) => {
|
||||
const res = await fetch(
|
||||
`/api/storage/upload-url?key=${encodeURIComponent(
|
||||
key
|
||||
)}&fileType=${encodeURIComponent(file.type)}`
|
||||
)
|
||||
const { url, fields } = await res.json()
|
||||
const formData = new FormData()
|
||||
|
||||
Object.entries({ ...fields, file }).forEach(([key, value]) => {
|
||||
formData.append(key, value as string | Blob)
|
||||
})
|
||||
|
||||
const upload = await fetch(url, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
return {
|
||||
url: upload.ok ? `${url}/${key}` : null,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user