2
0

♻️ (builder) Change to features-centric folder structure

This commit is contained in:
Baptiste Arnaud
2022-11-15 09:35:48 +01:00
committed by Baptiste Arnaud
parent 3686465a85
commit 643571fe7d
683 changed files with 3907 additions and 3643 deletions

2
.gitignore vendored
View File

@ -9,6 +9,8 @@ authenticatedState.json
playwright-report
dist
test-results
test/results
test/report
**/api/scripts
.sentryclirc
.docusaurus

View File

@ -26,5 +26,18 @@ module.exports = {
rules: {
'react/no-unescaped-entities': [0],
'react/display-name': [0],
'no-restricted-imports': [
'error',
{
patterns: [
'*/src/*',
'src/*',
'*/src',
'@/features/*/*',
'!@/features/blocks/*',
'!@/features/*/api',
],
},
],
},
}

View File

@ -1,21 +0,0 @@
export * from './GiphyLogo'
export * from './GitlabLogo'
export * from './GoogleAnalyticsLogo'
export * from './GoogleSheetsLogo'
export * from './GtmLogo'
export * from './IframeLogo'
export * from './JavascriptLogo'
export * from './NotionLogo'
export * from './OtherLogo'
export * from './ReactLogo'
export * from './ShopifyLogo'
export * from './TypebotLogo'
export * from './WebflowLogo'
export * from './WordpressLogo'
export * from './WixLogo'
export * from './GoogleLogo'
export * from './FacebookLogo'
export * from './ZapierLogo'
export * from './MakeComLogo'
export * from './PabblyConnectLogo'
export * from './AzureAdLogo'

View File

@ -1,21 +0,0 @@
import React from 'react'
import { Text } from '@chakra-ui/react'
import { TextLink } from 'components/shared/TextLink'
type Props = {
type: 'register' | 'signin'
}
export const AuthSwitcher = ({ type }: Props) => (
<>
{type === 'signin' ? (
<Text>
Don't have an account?{' '}
<TextLink href="/register">Sign up for free</TextLink>
</Text>
) : (
<Text>
Already have an account? <TextLink href="/signin">Sign in</TextLink>
</Text>
)}
</>
)

View File

@ -1,104 +0,0 @@
import { IconProps } from '@chakra-ui/react'
import {
BoxIcon,
CalendarIcon,
ChatIcon,
CheckSquareIcon,
CodeIcon,
CreditCardIcon,
EditIcon,
EmailIcon,
ExternalLinkIcon,
FilmIcon,
FilterIcon,
FlagIcon,
GlobeIcon,
ImageIcon,
LayoutIcon,
NumberIcon,
PhoneIcon,
SendEmailIcon,
StarIcon,
TextIcon,
UploadIcon,
WebhookIcon,
} from 'assets/icons'
import {
GoogleAnalyticsLogo,
GoogleSheetsLogo,
MakeComLogo,
PabblyConnectLogo,
ZapierLogo,
} from 'assets/logos'
import { ChatwootLogo } from 'features/chatwoot'
import {
BubbleBlockType,
InputBlockType,
IntegrationBlockType,
LogicBlockType,
BlockType,
} from 'models'
import React from 'react'
type BlockIconProps = { type: BlockType } & IconProps
export const BlockIcon = ({ type, ...props }: BlockIconProps) => {
switch (type) {
case BubbleBlockType.TEXT:
return <ChatIcon color="blue.500" {...props} />
case BubbleBlockType.IMAGE:
return <ImageIcon color="blue.500" {...props} />
case BubbleBlockType.VIDEO:
return <FilmIcon color="blue.500" {...props} />
case BubbleBlockType.EMBED:
return <LayoutIcon color="blue.500" {...props} />
case InputBlockType.TEXT:
return <TextIcon color="orange.500" {...props} />
case InputBlockType.NUMBER:
return <NumberIcon color="orange.500" {...props} />
case InputBlockType.EMAIL:
return <EmailIcon color="orange.500" {...props} />
case InputBlockType.URL:
return <GlobeIcon color="orange.500" {...props} />
case InputBlockType.DATE:
return <CalendarIcon color="orange.500" {...props} />
case InputBlockType.PHONE:
return <PhoneIcon color="orange.500" {...props} />
case InputBlockType.CHOICE:
return <CheckSquareIcon color="orange.500" {...props} />
case InputBlockType.PAYMENT:
return <CreditCardIcon color="orange.500" {...props} />
case InputBlockType.RATING:
return <StarIcon color="orange.500" {...props} />
case InputBlockType.FILE:
return <UploadIcon color="orange.500" {...props} />
case LogicBlockType.SET_VARIABLE:
return <EditIcon color="purple.500" {...props} />
case LogicBlockType.CONDITION:
return <FilterIcon color="purple.500" {...props} />
case LogicBlockType.REDIRECT:
return <ExternalLinkIcon color="purple.500" {...props} />
case LogicBlockType.CODE:
return <CodeIcon color="purple.500" {...props} />
case LogicBlockType.TYPEBOT_LINK:
return <BoxIcon color="purple.500" {...props} />
case IntegrationBlockType.GOOGLE_SHEETS:
return <GoogleSheetsLogo {...props} />
case IntegrationBlockType.GOOGLE_ANALYTICS:
return <GoogleAnalyticsLogo {...props} />
case IntegrationBlockType.WEBHOOK:
return <WebhookIcon {...props} />
case IntegrationBlockType.ZAPIER:
return <ZapierLogo {...props} />
case IntegrationBlockType.MAKE_COM:
return <MakeComLogo {...props} />
case IntegrationBlockType.PABBLY_CONNECT:
return <PabblyConnectLogo {...props} />
case IntegrationBlockType.EMAIL:
return <SendEmailIcon {...props} />
case IntegrationBlockType.CHATWOOT:
return <ChatwootLogo {...props} />
case 'start':
return <FlagIcon {...props} />
}
}

View File

@ -1,126 +0,0 @@
import {
Flex,
Heading,
HStack,
IconButton,
Stack,
Wrap,
Text,
} from '@chakra-ui/react'
import { TrashIcon } from 'assets/icons'
import { UpgradeButton } from 'components/shared/buttons/UpgradeButton'
import { useToast } from 'components/shared/hooks/useToast'
import { LockTag } from 'components/shared/LockTag'
import { LimitReached } from 'components/shared/modals/ChangePlanModal'
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
import { useWorkspace } from 'contexts/WorkspaceContext'
import { Plan } from 'db'
import React from 'react'
import { parseDefaultPublicId } from 'services/typebots'
import { isWorkspaceProPlan } from 'services/workspace'
import { getViewerUrl, isDefined, isNotDefined } from 'utils'
import { CustomDomainsDropdown } from './customDomain/CustomDomainsDropdown'
import { EditableUrl } from './EditableUrl'
import { integrationsList } from './integrations/EmbedButton'
import { isPublicDomainAvailableQuery } from './queries/isPublicDomainAvailableQuery'
export const ShareContent = () => {
const { workspace } = useWorkspace()
const { typebot, updateTypebot } = useTypebot()
const { showToast } = useToast()
const handlePublicIdChange = async (publicId: string) => {
if (publicId === typebot?.publicId) return
if (publicId.length < 4)
return showToast({ description: 'ID must be longer than 4 characters' })
const { data } = await isPublicDomainAvailableQuery(publicId)
if (!data?.isAvailable)
return showToast({ description: 'ID is already taken' })
updateTypebot({ publicId })
}
const publicId = typebot
? typebot?.publicId ?? parseDefaultPublicId(typebot.name, typebot.id)
: ''
const isPublished = isDefined(typebot?.publishedTypebotId)
const handlePathnameChange = (pathname: string) => {
if (!typebot?.customDomain) return
const existingHost = typebot.customDomain?.split('/')[0]
const newDomain =
pathname === '' ? existingHost : existingHost + '/' + pathname
handleCustomDomainChange(newDomain)
}
const handleCustomDomainChange = (customDomain: string | undefined) =>
updateTypebot({ customDomain })
return (
<Flex h="full" w="full" justifyContent="center" align="flex-start">
<Stack maxW="1000px" w="full" pt="10" spacing={10}>
<Stack spacing={4} align="flex-start">
<Heading fontSize="2xl" as="h1">
Your typebot link
</Heading>
{typebot && (
<EditableUrl
hostname={
getViewerUrl({ isBuilder: true }) ?? 'https://typebot.io'
}
pathname={publicId}
onPathnameChange={handlePublicIdChange}
/>
)}
{typebot?.customDomain && (
<HStack>
<EditableUrl
hostname={'https://' + typebot.customDomain.split('/')[0]}
pathname={typebot.customDomain.split('/')[1]}
onPathnameChange={handlePathnameChange}
/>
<IconButton
icon={<TrashIcon />}
aria-label="Remove custom domain"
size="xs"
onClick={() => handleCustomDomainChange(undefined)}
/>
</HStack>
)}
{isNotDefined(typebot?.customDomain) ? (
<>
{isWorkspaceProPlan(workspace) ? (
<CustomDomainsDropdown
onCustomDomainSelect={handleCustomDomainChange}
/>
) : (
<UpgradeButton
colorScheme="gray"
limitReachedType={LimitReached.CUSTOM_DOMAIN}
>
<Text mr="2">Add my domain</Text> <LockTag plan={Plan.PRO} />
</UpgradeButton>
)}
</>
) : null}
</Stack>
<Stack spacing={4}>
<Heading fontSize="2xl" as="h1">
Embed your typebot
</Heading>
<Wrap spacing={7}>
{integrationsList.map((IntegrationButton, idx) => (
<IntegrationButton
key={idx}
publicId={publicId}
isPublished={isPublished}
/>
))}
</Wrap>
</Stack>
</Stack>
</Flex>
)
}

View File

@ -1,164 +0,0 @@
import { Text } from '@chakra-ui/react'
import { ChatwootBlockNodeLabel } from 'features/chatwoot'
import {
Block,
StartBlock,
BubbleBlockType,
InputBlockType,
LogicBlockType,
IntegrationBlockType,
BlockIndices,
} from 'models'
import { isChoiceInput, isInputBlock } from 'utils'
import { ItemNodesList } from '../../ItemNode'
import {
EmbedBubbleContent,
SetVariableContent,
TextBubbleContent,
VideoBubbleContent,
WebhookContent,
WithVariableContent,
} from './contents'
import { ConfigureContent } from './contents/ConfigureContent'
import { FileInputContent } from './contents/FileInputContent'
import { ImageBubbleContent } from './contents/ImageBubbleContent'
import { PaymentInputContent } from './contents/PaymentInputContent'
import { PlaceholderContent } from './contents/PlaceholderContent'
import { RatingInputContent } from './contents/RatingInputContent'
import { SendEmailContent } from './contents/SendEmailContent'
import { TypebotLinkContent } from './contents/TypebotLinkContent'
import { ProviderWebhookContent } from './contents/ZapierContent'
type Props = {
block: Block | StartBlock
indices: BlockIndices
}
export const BlockNodeContent = ({ block, indices }: Props): JSX.Element => {
if (
isInputBlock(block) &&
!isChoiceInput(block) &&
block.options.variableId
) {
return <WithVariableContent block={block} />
}
switch (block.type) {
case BubbleBlockType.TEXT: {
return <TextBubbleContent block={block} />
}
case BubbleBlockType.IMAGE: {
return <ImageBubbleContent block={block} />
}
case BubbleBlockType.VIDEO: {
return <VideoBubbleContent block={block} />
}
case BubbleBlockType.EMBED: {
return <EmbedBubbleContent block={block} />
}
case InputBlockType.TEXT: {
return (
<PlaceholderContent
placeholder={block.options.labels.placeholder}
isLong={block.options.isLong}
/>
)
}
case InputBlockType.NUMBER:
case InputBlockType.EMAIL:
case InputBlockType.URL:
case InputBlockType.PHONE: {
return (
<PlaceholderContent placeholder={block.options.labels.placeholder} />
)
}
case InputBlockType.DATE: {
return <Text color={'gray.500'}>Pick a date...</Text>
}
case InputBlockType.CHOICE: {
return <ItemNodesList block={block} indices={indices} />
}
case InputBlockType.PAYMENT: {
return <PaymentInputContent block={block} />
}
case InputBlockType.RATING: {
return <RatingInputContent block={block} />
}
case InputBlockType.FILE: {
return <FileInputContent options={block.options} />
}
case LogicBlockType.SET_VARIABLE: {
return <SetVariableContent block={block} />
}
case LogicBlockType.CONDITION: {
return <ItemNodesList block={block} indices={indices} isReadOnly />
}
case LogicBlockType.REDIRECT: {
return (
<ConfigureContent
label={
block.options?.url ? `Redirect to ${block.options?.url}` : undefined
}
/>
)
}
case LogicBlockType.CODE: {
return (
<ConfigureContent
label={
block.options?.content ? `Run ${block.options?.name}` : undefined
}
/>
)
}
case LogicBlockType.TYPEBOT_LINK:
return <TypebotLinkContent block={block} />
case IntegrationBlockType.GOOGLE_SHEETS: {
return (
<ConfigureContent
label={
block.options && 'action' in block.options
? block.options.action
: undefined
}
/>
)
}
case IntegrationBlockType.GOOGLE_ANALYTICS: {
return (
<ConfigureContent
label={
block.options?.action
? `Track "${block.options?.action}" `
: undefined
}
/>
)
}
case IntegrationBlockType.WEBHOOK: {
return <WebhookContent block={block} />
}
case IntegrationBlockType.ZAPIER: {
return (
<ProviderWebhookContent block={block} configuredLabel="Trigger zap" />
)
}
case IntegrationBlockType.PABBLY_CONNECT:
case IntegrationBlockType.MAKE_COM: {
return (
<ProviderWebhookContent
block={block}
configuredLabel="Trigger scenario"
/>
)
}
case IntegrationBlockType.EMAIL: {
return <SendEmailContent block={block} />
}
case IntegrationBlockType.CHATWOOT: {
return <ChatwootBlockNodeLabel block={block} />
}
case 'start': {
return <Text>Start</Text>
}
}
}

View File

@ -1,10 +0,0 @@
import React from 'react'
import { Text } from '@chakra-ui/react'
type Props = { label?: string }
export const ConfigureContent = ({ label }: Props) => (
<Text color={label ? 'currentcolor' : 'gray.500'} noOfLines={1}>
{label ?? 'Configure...'}
</Text>
)

View File

@ -1,10 +0,0 @@
import React from 'react'
import { Text } from '@chakra-ui/react'
type Props = { placeholder: string; isLong?: boolean }
export const PlaceholderContent = ({ placeholder, isLong }: Props) => (
<Text color={'gray.500'} h={isLong ? '100px' : 'auto'}>
{placeholder}
</Text>
)

View File

@ -1,6 +0,0 @@
export * from './SetVariableContent'
export * from './WithVariableContent'
export * from './VideoBubbleContent'
export * from './WebhookContent'
export * from './TextBubbleContent'
export * from './EmbedBubbleContent'

View File

@ -1 +0,0 @@
export { MediaBubblePopoverContent } from './MediaBubblePopoverContent'

View File

@ -1,5 +0,0 @@
export * from './DateInputSettingsBody'
export * from './EmailInputSettingsBody'
export * from './NumberInputSettingsBody'
export * from './TextInputSettingsBody'
export * from './UrlInputSettingsBody'

View File

@ -1,71 +0,0 @@
import React from 'react'
import {
KBarPortal,
KBarPositioner,
KBarAnimator,
KBarSearch,
KBarResults,
useMatches,
} from 'kbar'
import { chakra, Flex } from '@chakra-ui/react'
// eslint-disable-next-line @typescript-eslint/ban-types
type KBarProps = {}
const KBarSearchChakra = chakra(KBarSearch)
const KBarAnimatorChakra = chakra(KBarAnimator)
const KBarResultsChakra = chakra(KBarResults)
export const KBar = ({}: KBarProps) => {
return (
<KBarPortal>
<KBarPositioner>
<KBarAnimatorChakra shadow="2xl" rounded="md">
<KBarSearchChakra
p={4}
w="500px"
roundedTop="md"
_focus={{ outline: 'none' }}
/>
<RenderResults />
</KBarAnimatorChakra>
</KBarPositioner>
</KBarPortal>
)
}
const RenderResults = () => {
const { results } = useMatches()
return (
<KBarResultsChakra
items={results}
onRender={({ item, active }) =>
typeof item === 'string' ? (
<Flex height="50px">{item}</Flex>
) : (
<Flex
height="50px"
roundedBottom="md"
align="center"
px="4"
bgColor={active ? 'blue.50' : 'white'}
_hover={{ bgColor: 'blue.50' }}
>
{active && (
<Flex
pos="absolute"
left="0"
h="full"
w="3px"
roundedRight="md"
bgColor={'blue.500'}
/>
)}
{item.name}
</Flex>
)
}
/>
)
}

View File

@ -1,13 +0,0 @@
import { Heading, Text, VStack } from '@chakra-ui/react'
import { TypebotLogo } from 'assets/logos'
import React from 'react'
export const MaintenancePage = () => (
<VStack h="100vh" justify="center">
<TypebotLogo />
<Heading>
The tool is under maintenance for an exciting new feature! 🤩
</Heading>
<Text>Please come back again in 10 minutes.</Text>
</VStack>
)

View File

@ -1 +0,0 @@
export { ChangePlanModal } from './ChangePlanModal'

View File

@ -1 +0,0 @@
export { TypebotContext, useTypebot } from './TypebotContext'

View File

@ -1,3 +0,0 @@
export { ChatwootLogo } from './ChatwootLogo'
export { ChatwootBlockNodeLabel } from './ChatwootBlockNodeLabel'
export { ChatwootSettingsForm } from './ChatwootSettingsForm'

View File

@ -1 +0,0 @@
export * from './components'

View File

@ -1,11 +0,0 @@
import { TypebotLogo } from 'assets/logos'
import { Spinner, VStack } from '@chakra-ui/react'
export const LoadingPage = () => (
<div className="flex h-screen items-center justify-center">
<VStack spacing={6}>
<TypebotLogo boxSize="80px" />
<Spinner />
</VStack>
</div>
)

View File

@ -1,30 +0,0 @@
import { Flex } from '@chakra-ui/react'
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
import React, { useMemo } from 'react'
import { TypebotViewer } from 'bot-engine'
import { parseTypebotToPublicTypebot } from 'services/publicTypebot'
import { SettingsSideMenu } from 'components/settings/SettingsSideMenu'
import { getViewerUrl } from 'utils'
export const SettingsContent = () => {
const { typebot } = useTypebot()
const publicTypebot = useMemo(
() => (typebot ? parseTypebotToPublicTypebot(typebot) : undefined),
// eslint-disable-next-line react-hooks/exhaustive-deps
[typebot?.settings]
)
return (
<Flex h="full" w="full">
<SettingsSideMenu />
<Flex flex="1">
{publicTypebot && (
<TypebotViewer
apiHost={getViewerUrl({ isBuilder: true })}
typebot={publicTypebot}
/>
)}
</Flex>
</Flex>
)
}

View File

@ -1,25 +0,0 @@
import { Flex } from '@chakra-ui/react'
import { TypebotViewer } from 'bot-engine'
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
import React from 'react'
import { parseTypebotToPublicTypebot } from 'services/publicTypebot'
import { getViewerUrl } from 'utils'
import { ThemeSideMenu } from '../../components/theme/ThemeSideMenu'
export const ThemeContent = () => {
const { typebot } = useTypebot()
const publicTypebot = typebot && parseTypebotToPublicTypebot(typebot)
return (
<Flex h="full" w="full">
<ThemeSideMenu />
<Flex flex="1">
{publicTypebot && (
<TypebotViewer
apiHost={getViewerUrl({ isBuilder: true })}
typebot={publicTypebot}
/>
)}
</Flex>
</Flex>
)
}

View File

@ -1 +0,0 @@
export const actions = []

View File

@ -56,7 +56,6 @@
"immer": "9.0.16",
"js-video-url-parser": "0.5.1",
"jsonwebtoken": "8.5.1",
"kbar": "0.1.0-beta.36",
"micro": "9.4.1",
"micro-cors": "0.1.1",
"minio": "7.0.32",

View File

@ -1,21 +0,0 @@
import { AuthSwitcher } from 'components/auth/AuthSwitcher'
import { SignInForm } from 'components/auth/SignInForm'
import { Heading, VStack } from '@chakra-ui/react'
import { useRouter } from 'next/router'
import React from 'react'
import { Seo } from 'components/Seo'
const RegisterPage = () => {
const { query } = useRouter()
return (
<VStack spacing={4} h="100vh" justifyContent="center">
<Seo title="Register" />
<Heading>Create an account</Heading>
<AuthSwitcher type="register" />
<SignInForm defaultEmail={query.g?.toString()} />
</VStack>
)
}
export default RegisterPage

View File

@ -1,24 +0,0 @@
import { AuthSwitcher } from 'components/auth/AuthSwitcher'
import { SignInForm } from 'components/auth/SignInForm'
import { Heading, VStack } from '@chakra-ui/react'
import React from 'react'
import { Seo } from 'components/Seo'
const SignInPage = () => {
return (
<VStack spacing={4} h="100vh" justifyContent="center">
<Seo title="Sign in" />
<Heading
onClick={() => {
throw new Error('Sentry is working')
}}
>
Sign in
</Heading>
<AuthSwitcher type="signin" />
<SignInForm />
</VStack>
)
}
export default SignInPage

View File

@ -1,15 +0,0 @@
import { Flex } from '@chakra-ui/react'
import { Seo } from 'components/Seo'
import { SettingsContent } from 'layouts/settings/SettingsContent'
import { TypebotHeader } from 'components/shared/TypebotHeader'
import React from 'react'
const SettingsPage = () => (
<Flex overflow="hidden" h="100vh" flexDir="column">
<Seo title="Settings" />
<TypebotHeader />
<SettingsContent />
</Flex>
)
export default SettingsPage

View File

@ -1,15 +0,0 @@
import { Flex } from '@chakra-ui/react'
import { Seo } from 'components/Seo'
import { ShareContent } from 'components/share/ShareContent'
import { TypebotHeader } from 'components/shared/TypebotHeader'
import React from 'react'
const SharePage = () => (
<Flex flexDir="column" pb="40">
<Seo title="Share" />
<TypebotHeader />
<ShareContent />
</Flex>
)
export default SharePage

View File

@ -1,15 +0,0 @@
import { Flex } from '@chakra-ui/react'
import { Seo } from 'components/Seo'
import { TypebotHeader } from 'components/shared/TypebotHeader'
import { ThemeContent } from 'layouts/theme/ThemeContent'
import React from 'react'
const ThemePage = () => (
<Flex overflow="hidden" h="100vh" flexDir="column">
<Seo title="Theme" />
<TypebotHeader />
<ThemeContent />
</Flex>
)
export default ThemePage

View File

@ -1,15 +0,0 @@
import React from 'react'
import { VStack } from '@chakra-ui/react'
import { Seo } from 'components/Seo'
import { DashboardHeader } from 'components/dashboard/DashboardHeader'
import { CreateNewTypebotButtons } from 'components/templates/CreateNewTypebotButtons'
const TemplatesPage = () => (
<VStack>
<Seo title="Templates" />
<DashboardHeader />
<CreateNewTypebotButtons />
</VStack>
)
export default TemplatesPage

View File

@ -1,44 +0,0 @@
import React from 'react'
import { Flex, Stack } from '@chakra-ui/react'
import { DashboardHeader } from 'components/dashboard/DashboardHeader'
import { Seo } from 'components/Seo'
import { FolderContent } from 'components/dashboard/FolderContent'
import { useRouter } from 'next/router'
import { useFolderContent } from 'services/folders'
import { Spinner } from '@chakra-ui/react'
import { TypebotDndContext } from 'contexts/TypebotDndContext'
import { useToast } from 'components/shared/hooks/useToast'
const FolderPage = () => {
const router = useRouter()
const { showToast } = useToast()
const { folder } = useFolderContent({
folderId: router.query.id?.toString(),
onError: (error) => {
showToast({
title: "Couldn't fetch folder content",
description: error.message,
})
},
})
return (
<Stack minH="100vh">
<Seo title="My typebots" />
<DashboardHeader />
<TypebotDndContext>
{!folder ? (
<Flex flex="1">
<Spinner mx="auto" />
</Flex>
) : (
<FolderContent folder={folder} />
)}
</TypebotDndContext>
</Stack>
)
}
export default FolderPage

View File

@ -13,9 +13,9 @@ const config: PlaywrightTestConfig = {
use: {
...playwrightBaseConfig.use,
baseURL: process.env.NEXTAUTH_URL,
storageState: path.join(__dirname, 'playwright/firstUser.json'),
storageState: path.join(__dirname, 'src/test/storageState.json'),
},
outputDir: path.join(__dirname, 'playwright/test-results/'),
outputDir: path.join(__dirname, 'src/test/results/'),
}
export default config

View File

@ -1,14 +0,0 @@
PLAYWRIGHT_BUILDER_TEST_BASE_URL=http://localhost:3000
DATABASE_URL=postgresql://postgres:typebot@localhost:5432/typebot
ENCRYPTION_SECRET=SgVkYp2s5v8y/B?E(H+MbQeThWmZq4t6 #256-bits secret (can be generated here: https://www.allkeysgenerator.com/Random/Security-Encryption-Key-Generator.aspx)
# SMTP Credentials (Generated on https://ethereal.email/)
SMTP_HOST=smtp.ethereal.email
SMTP_PORT=587
SMTP_SECURE=true
SMTP_USERNAME=tobin.tillman65@ethereal.email
SMTP_PASSWORD=Ty9BcwCBrK6w8AG2hx
STRIPE_TEST_PUBLIC_KEY=
STRIPE_TEST_SECRET_KEY=
STRIPE_STARTER_PRICE_ID=

View File

@ -1,15 +0,0 @@
{
"cookies": [],
"origins": [
{
"origin": "http://localhost:3000",
"localStorage": [
{
"name": "typebot-20-modal",
"value": "hide"
},
{ "name": "workspaceId", "value": "freeWorkspace" }
]
}
]
}

View File

@ -1,45 +0,0 @@
import { Stats } from 'models'
import useSWR from 'swr'
import { fetcher } from './utils'
export const useStats = ({
typebotId,
onError,
}: {
typebotId?: string
onError: (error: Error) => void
}) => {
const { data, error, mutate } = useSWR<{ stats: Stats }, Error>(
typebotId ? `/api/typebots/${typebotId}/results/stats` : null,
fetcher
)
if (error) onError(error)
return {
stats: data?.stats,
isLoading: !error && !data,
mutate,
}
}
export type AnswersCount = { groupId: string; totalAnswers: number }
export const useAnswersCount = ({
typebotId,
onError,
}: {
typebotId?: string
onError: (error: Error) => void
}) => {
const { data, error, mutate } = useSWR<
{ answersCounts: AnswersCount[] },
Error
>(
typebotId ? `/api/typebots/${typebotId}/results/answers/count` : null,
fetcher
)
if (error) onError(error)
return {
answersCounts: data?.answersCounts,
isLoading: !error && !data,
mutate,
}
}

View File

@ -1,8 +0,0 @@
import { sendRequest } from 'utils'
export const redeemCoupon = async (code: string) =>
sendRequest<{ message: string }>({
method: 'POST',
url: '/api/coupons/redeem',
body: { code },
})

View File

@ -1,48 +0,0 @@
import { Credentials } from 'models'
import { stringify } from 'qs'
import useSWR from 'swr'
import { sendRequest } from 'utils'
import { fetcher } from './utils'
export const useCredentials = ({
workspaceId,
onError,
}: {
workspaceId?: string
onError?: (error: Error) => void
}) => {
const { data, error, mutate } = useSWR<{ credentials: Credentials[] }, Error>(
workspaceId ? `/api/credentials?${stringify({ workspaceId })}` : null,
fetcher
)
if (error && onError) onError(error)
return {
credentials: data?.credentials ?? [],
isLoading: !error && !data,
mutate,
}
}
export const createCredentials = async (
credentials: Omit<Credentials, 'id' | 'iv' | 'createdAt'>
) =>
sendRequest<{
credentials: Credentials
}>({
url: `/api/credentials?${stringify({
workspaceId: credentials.workspaceId,
})}`,
method: 'POST',
body: credentials,
})
export const deleteCredentials = async (
workspaceId: string,
credentialsId: string
) =>
sendRequest<{
credentials: Credentials
}>({
url: `/api/credentials/${credentialsId}?${stringify({ workspaceId })}`,
method: 'DELETE',
})

View File

@ -1,51 +0,0 @@
import { CustomDomain } from 'db'
import { Credentials } from 'models'
import { stringify } from 'qs'
import useSWR from 'swr'
import { sendRequest } from 'utils'
import { fetcher } from './utils'
export const useCustomDomains = ({
workspaceId,
onError,
}: {
workspaceId?: string
onError: (error: Error) => void
}) => {
const { data, error, mutate } = useSWR<
{ customDomains: Omit<CustomDomain, 'createdAt'>[] },
Error
>(
workspaceId ? `/api/customDomains?${stringify({ workspaceId })}` : null,
fetcher
)
if (error) onError(error)
return {
customDomains: data?.customDomains,
isLoading: !error && !data,
mutate,
}
}
export const createCustomDomain = async (
workspaceId: string,
customDomain: Omit<CustomDomain, 'createdAt' | 'workspaceId'>
) =>
sendRequest<{
credentials: Credentials
}>({
url: `/api/customDomains?${stringify({ workspaceId })}`,
method: 'POST',
body: customDomain,
})
export const deleteCustomDomain = async (
workspaceId: string,
customDomain: string
) =>
sendRequest<{
credentials: Credentials
}>({
url: `/api/customDomains/${customDomain}?${stringify({ workspaceId })}`,
method: 'DELETE',
})

View File

@ -1,75 +0,0 @@
import { DashboardFolder } from 'db'
import useSWR from 'swr'
import { fetcher } from './utils'
import { stringify } from 'qs'
import { env, sendRequest } from 'utils'
export const useFolders = ({
parentId,
workspaceId,
onError,
}: {
workspaceId?: string
parentId?: string
onError: (error: Error) => void
}) => {
const params = stringify({ parentId, workspaceId })
const { data, error, mutate } = useSWR<{ folders: DashboardFolder[] }, Error>(
workspaceId ? `/api/folders?${params}` : null,
fetcher,
{
dedupingInterval: env('E2E_TEST') === 'true' ? 0 : undefined,
}
)
if (error) onError(error)
return {
folders: data?.folders,
isLoading: !error && !data,
mutate,
}
}
export const useFolderContent = ({
folderId,
onError,
}: {
folderId?: string
onError: (error: Error) => void
}) => {
const { data, error, mutate } = useSWR<{ folder: DashboardFolder }, Error>(
`/api/folders/${folderId}`,
fetcher
)
if (error) onError(error)
return {
folder: data?.folder,
isLoading: !error && !data,
mutate,
}
}
export const createFolder = async (
workspaceId: string,
folder: Pick<DashboardFolder, 'parentFolderId'>
) =>
sendRequest<DashboardFolder>({
url: `/api/folders`,
method: 'POST',
body: { ...folder, workspaceId },
})
export const deleteFolder = async (id: string) =>
sendRequest({
url: `/api/folders/${id}`,
method: 'DELETE',
})
export const updateFolder = async (
id: string,
folder: Partial<DashboardFolder>
) =>
sendRequest({
url: `/api/folders/${id}`,
method: 'PATCH',
body: folder,
})

View File

@ -1,143 +0,0 @@
import { getViewerUrl, sendRequest } from 'utils'
import { stringify } from 'qs'
import useSWR from 'swr'
import { fetcher } from './utils'
import {
SmtpCredentialsData,
Variable,
VariableForTest,
WebhookResponse,
} from 'models'
export const getGoogleSheetsConsentScreenUrl = (
redirectUrl: string,
blockId: string,
workspaceId?: string
) => {
const queryParams = stringify({ redirectUrl, blockId, workspaceId })
return `/api/credentials/google-sheets/consent-url?${queryParams}`
}
export const createSheetsAccount = async (code: string) => {
const queryParams = stringify({ code })
return sendRequest({
url: `/api/credentials/google-sheets/callback?${queryParams}`,
method: 'GET',
})
}
export type Spreadsheet = { id: string; name: string }
export const useSpreadsheets = ({
credentialsId,
onError,
}: {
credentialsId: string
onError?: (error: Error) => void
}) => {
const queryParams = stringify({ credentialsId })
const { data, error, mutate } = useSWR<{ files: Spreadsheet[] }, Error>(
`/api/integrations/google-sheets/spreadsheets?${queryParams}`,
fetcher
)
if (error) onError && onError(error)
return {
spreadsheets: data?.files,
isLoading: !error && !data,
mutate,
}
}
export type Sheet = { id: string; name: string; columns: string[] }
export const useSheets = ({
credentialsId,
spreadsheetId,
onError,
}: {
credentialsId?: string
spreadsheetId?: string
onError?: (error: Error) => void
}) => {
const queryParams = stringify({ credentialsId })
const { data, error, mutate } = useSWR<{ sheets: Sheet[] }, Error>(
!credentialsId || !spreadsheetId
? null
: `/api/integrations/google-sheets/spreadsheets/${spreadsheetId}/sheets?${queryParams}`,
fetcher
)
if (error) onError && onError(error)
return {
sheets: data?.sheets,
isLoading: !error && !data,
mutate,
}
}
export const executeWebhook = (
typebotId: string,
variables: Variable[],
{ blockId }: { blockId: string }
) =>
sendRequest<WebhookResponse>({
url: `${getViewerUrl({
isBuilder: true,
})}/api/typebots/${typebotId}/blocks/${blockId}/executeWebhook`,
method: 'POST',
body: {
variables,
},
})
export const convertVariableForTestToVariables = (
variablesForTest: VariableForTest[],
variables: Variable[]
): Variable[] => {
if (!variablesForTest) return []
return [
...variables,
...variablesForTest
.filter((v) => v.variableId)
.map((variableForTest) => {
const variable = variables.find(
(v) => v.id === variableForTest.variableId
) as Variable
return { ...variable, value: variableForTest.value }
}, {}),
]
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const getDeepKeys = (obj: any): string[] => {
let keys: string[] = []
for (const key in obj) {
if (typeof obj[key] === 'object' && !Array.isArray(obj[key])) {
const subkeys = getDeepKeys(obj[key])
keys = keys.concat(
subkeys.map(function (subkey) {
return key + '.' + subkey
})
)
} else if (Array.isArray(obj[key])) {
for (let i = 0; i < obj[key].length; i++) {
const subkeys = getDeepKeys(obj[key][i])
keys = keys.concat(
subkeys.map(function (subkey) {
return key + '[' + i + ']' + '.' + subkey
})
)
}
} else {
keys.push(key)
}
}
return keys
}
export const testSmtpConfig = (smtpData: SmtpCredentialsData, to: string) =>
sendRequest({
method: 'POST',
url: '/api/integrations/email/test-config',
body: {
...smtpData,
to,
},
})

View File

@ -1,61 +0,0 @@
import cuid from 'cuid'
import { PublicTypebot, Typebot } from 'models'
import { sendRequest } from 'utils'
export const parseTypebotToPublicTypebot = (
typebot: Typebot
): PublicTypebot => ({
id: cuid(),
typebotId: typebot.id,
groups: typebot.groups,
edges: typebot.edges,
settings: typebot.settings,
theme: typebot.theme,
variables: typebot.variables,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
})
export const parsePublicTypebotToTypebot = (
typebot: PublicTypebot,
existingTypebot: Typebot
): Typebot => ({
id: typebot.typebotId,
groups: typebot.groups,
edges: typebot.edges,
name: existingTypebot.name,
publicId: existingTypebot.publicId,
settings: typebot.settings,
theme: typebot.theme,
variables: typebot.variables,
customDomain: existingTypebot.customDomain,
createdAt: existingTypebot.createdAt,
updatedAt: existingTypebot.updatedAt,
publishedTypebotId: typebot.id,
folderId: existingTypebot.folderId,
icon: existingTypebot.icon,
workspaceId: existingTypebot.workspaceId,
isArchived: existingTypebot.isArchived,
isClosed: existingTypebot.isClosed,
})
export const createPublishedTypebot = async (
typebot: PublicTypebot,
workspaceId: string
) =>
sendRequest<PublicTypebot>({
url: `/api/publicTypebots?workspaceId=${workspaceId}`,
method: 'POST',
body: typebot,
})
export const updatePublishedTypebot = async (
id: string,
typebot: Omit<PublicTypebot, 'id'>,
workspaceId: string
) =>
sendRequest({
url: `/api/publicTypebots/${id}?workspaceId=${workspaceId}`,
method: 'PUT',
body: typebot,
})

View File

@ -1,48 +0,0 @@
import { CollaboratorsOnTypebots } from 'db'
import { fetcher } from 'services/utils'
import useSWR from 'swr'
import { sendRequest } from 'utils'
export type Collaborator = CollaboratorsOnTypebots & {
user: {
name: string | null
image: string | null
email: string | null
}
}
export const useCollaborators = ({
typebotId,
onError,
}: {
typebotId?: string
onError: (error: Error) => void
}) => {
const { data, error, mutate } = useSWR<
{ collaborators: Collaborator[] },
Error
>(typebotId ? `/api/typebots/${typebotId}/collaborators` : null, fetcher)
if (error) onError(error)
return {
collaborators: data?.collaborators,
isLoading: !error && !data,
mutate,
}
}
export const updateCollaborator = (
typebotId: string,
userId: string,
collaborator: CollaboratorsOnTypebots
) =>
sendRequest({
method: 'PATCH',
url: `/api/typebots/${typebotId}/collaborators/${userId}`,
body: collaborator,
})
export const deleteCollaborator = (typebotId: string, userId: string) =>
sendRequest({
method: 'DELETE',
url: `/api/typebots/${typebotId}/collaborators/${userId}`,
})

View File

@ -1 +0,0 @@
export * from './typebots'

View File

@ -1,53 +0,0 @@
import { CollaborationType, Invitation } from 'db'
import { fetcher } from 'services/utils'
import useSWR from 'swr'
import { env, sendRequest } from 'utils'
export const useInvitations = ({
typebotId,
onError,
}: {
typebotId?: string
onError: (error: Error) => void
}) => {
const { data, error, mutate } = useSWR<{ invitations: Invitation[] }, Error>(
typebotId ? `/api/typebots/${typebotId}/invitations` : null,
fetcher,
{
dedupingInterval: env('E2E_TEST') === 'true' ? 0 : undefined,
}
)
if (error) onError(error)
return {
invitations: data?.invitations,
isLoading: !error && !data,
mutate,
}
}
export const sendInvitation = (
typebotId: string,
{ email, type }: { email: string; type: CollaborationType }
) =>
sendRequest({
method: 'POST',
url: `/api/typebots/${typebotId}/invitations`,
body: { email, type },
})
export const updateInvitation = (
typebotId: string,
email: string,
invitation: Omit<Invitation, 'createdAt' | 'id'>
) =>
sendRequest({
method: 'PATCH',
url: `/api/typebots/${typebotId}/invitations/${email}`,
body: invitation,
})
export const deleteInvitation = (typebotId: string, email: string) =>
sendRequest({
method: 'DELETE',
url: `/api/typebots/${typebotId}/invitations/${email}`,
})

View File

@ -1,225 +0,0 @@
import {
ResultWithAnswers,
VariableWithValue,
ResultHeaderCell,
InputBlockType,
} from 'models'
import useSWRInfinite from 'swr/infinite'
import { stringify } from 'qs'
import { Answer } from 'db'
import { env, isDefined, sendRequest } from 'utils'
import { fetcher } from 'services/utils'
import { HStack, Text, Wrap, WrapItem } from '@chakra-ui/react'
import { CodeIcon, CalendarIcon, FileIcon } from 'assets/icons'
import { Link } from '@chakra-ui/react'
import { BlockIcon } from 'components/editor/BlocksSideBar/BlockIcon'
const paginationLimit = 50
const getKey = (
workspaceId: string,
typebotId: string,
pageIndex: number,
previousPageData: {
results: ResultWithAnswers[]
}
) => {
if (previousPageData && previousPageData.results.length === 0) return null
if (pageIndex === 0)
return `/api/typebots/${typebotId}/results?limit=50&workspaceId=${workspaceId}`
return `/api/typebots/${typebotId}/results?lastResultId=${
previousPageData.results[previousPageData.results.length - 1].id
}&limit=${paginationLimit}&workspaceId=${workspaceId}`
}
export const useResults = ({
workspaceId,
typebotId,
onError,
}: {
workspaceId: string
typebotId: string
onError?: (error: Error) => void
}) => {
const { data, error, mutate, setSize, size, isValidating } = useSWRInfinite<
{ results: ResultWithAnswers[] },
Error
>(
(
pageIndex: number,
previousPageData: {
results: ResultWithAnswers[]
}
) => getKey(workspaceId, typebotId, pageIndex, previousPageData),
fetcher,
{
revalidateAll: true,
dedupingInterval: env('E2E_TEST') === 'true' ? 0 : undefined,
}
)
if (error && onError) onError(error)
return {
data,
isLoading: !error && !data,
mutate,
setSize,
size,
hasMore:
isValidating ||
(data &&
data.length > 0 &&
data[data.length - 1].results.length > 0 &&
data.length === paginationLimit),
}
}
export const deleteResults = async (
workspaceId: string,
typebotId: string,
ids: string[]
) => {
const params = stringify({
workspaceId,
})
return sendRequest({
url: `/api/typebots/${typebotId}/results?${params}`,
method: 'DELETE',
body: {
ids,
},
})
}
export const getAllResults = async (workspaceId: string, typebotId: string) => {
const results = []
let hasMore = true
let lastResultId: string | undefined = undefined
do {
const query = stringify({ limit: 200, lastResultId, workspaceId })
const { data, error } = await sendRequest<{ results: ResultWithAnswers[] }>(
{
url: `/api/typebots/${typebotId}/results?${query}`,
method: 'GET',
}
)
if (error) {
console.error(error)
break
}
results.push(...(data?.results ?? []))
lastResultId = results[results.length - 1]?.id as string | undefined
if (data?.results.length === 0) hasMore = false
} while (hasMore)
return results
}
export const parseDateToReadable = (dateStr: string): string => {
const date = new Date(dateStr)
return (
date.toDateString().split(' ').slice(1, 3).join(' ') +
', ' +
date.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})
)
}
type HeaderCell = {
Header: JSX.Element
accessor: string
}
export const parseSubmissionsColumns = (
resultHeader: ResultHeaderCell[]
): HeaderCell[] =>
resultHeader.map((header) => ({
Header: (
<HStack minW="150px" maxW="500px">
<HeaderIcon header={header} />
<Text>{header.label}</Text>
</HStack>
),
accessor: header.label,
}))
export const HeaderIcon = ({ header }: { header: ResultHeaderCell }) =>
header.blockType ? (
<BlockIcon type={header.blockType} />
) : header.variableIds ? (
<CodeIcon />
) : (
<CalendarIcon />
)
export type CellValueType = { element?: JSX.Element; plainText: string }
export type TableData = {
id: Pick<CellValueType, 'plainText'>
} & Record<string, CellValueType>
export const convertResultsToTableData = (
results: ResultWithAnswers[] | undefined,
headerCells: ResultHeaderCell[]
): TableData[] =>
(results ?? []).map((result) => ({
id: { plainText: result.id },
'Submitted at': {
plainText: parseDateToReadable(result.createdAt),
},
...[...result.answers, ...result.variables].reduce<{
[key: string]: { element?: JSX.Element; plainText: string }
}>((o, answerOrVariable) => {
if ('groupId' in answerOrVariable) {
const answer = answerOrVariable as Answer
const header = answer.variableId
? headerCells.find((headerCell) =>
headerCell.variableIds?.includes(answer.variableId as string)
)
: headerCells.find((headerCell) =>
headerCell.blocks?.some((block) => block.id === answer.blockId)
)
if (!header || !header.blocks || !header.blockType) return o
return {
...o,
[header.label]: {
element: parseContent(answer.content, header.blockType),
plainText: answer.content,
},
}
}
const variable = answerOrVariable as VariableWithValue
const key = headerCells.find((headerCell) =>
headerCell.variableIds?.includes(variable.id)
)?.label
if (!key) return o
if (isDefined(o[key])) return o
return {
...o,
[key]: { plainText: variable.value?.toString() },
}
}, {}),
}))
const parseContent = (
str: string,
blockType: InputBlockType
): JSX.Element | undefined =>
blockType === InputBlockType.FILE ? parseFileContent(str) : undefined
const parseFileContent = (str: string) => {
const fileNames = str.split(', ')
return (
<Wrap maxW="300px">
{fileNames.map((name) => (
<HStack as={WrapItem} key={name}>
<FileIcon />
<Link href={name} isExternal color="blue.500">
{name.split('/').pop()}
</Link>
</HStack>
))}
</Wrap>
)
}

View File

@ -1,463 +0,0 @@
import {
Group,
PublicTypebot,
StartBlock,
BubbleBlockType,
InputBlockType,
LogicBlockType,
Block,
DraggableBlockType,
DraggableBlock,
defaultTheme,
defaultSettings,
BlockOptions,
BubbleBlockContent,
IntegrationBlockType,
defaultTextBubbleContent,
defaultImageBubbleContent,
defaultVideoBubbleContent,
defaultTextInputOptions,
defaultNumberInputOptions,
defaultEmailInputOptions,
defaultDateInputOptions,
defaultPhoneInputOptions,
defaultUrlInputOptions,
defaultChoiceInputOptions,
defaultSetVariablesOptions,
defaultRedirectOptions,
defaultGoogleSheetsOptions,
defaultGoogleAnalyticsOptions,
defaultCodeOptions,
defaultWebhookOptions,
BlockWithOptionsType,
Item,
ItemType,
defaultConditionContent,
defaultSendEmailOptions,
defaultEmbedBubbleContent,
ChoiceInputBlock,
ConditionBlock,
defaultPaymentInputOptions,
defaultRatingInputOptions,
defaultFileInputOptions,
} from 'models'
import { Typebot } from 'models'
import useSWR from 'swr'
import { fetcher, toKebabCase } from '../utils'
import {
isBubbleBlockType,
isWebhookBlock,
omit,
blockHasItems,
blockTypeHasItems,
blockTypeHasOption,
blockTypeHasWebhook,
env,
isDefined,
} from 'utils'
import { dequal } from 'dequal'
import { stringify } from 'qs'
import { isChoiceInput, isConditionBlock, sendRequest } from 'utils'
import cuid from 'cuid'
import { diff } from 'deep-object-diff'
import { duplicateWebhook } from 'services/webhook'
import { Plan } from 'db'
import { defaultChatwootOptions } from 'models'
export type TypebotInDashboard = Pick<
Typebot,
'id' | 'name' | 'publishedTypebotId' | 'icon'
>
export const useTypebots = ({
folderId,
workspaceId,
allFolders,
onError,
}: {
workspaceId?: string
folderId?: string
allFolders?: boolean
onError: (error: Error) => void
}) => {
const params = stringify({ folderId, allFolders, workspaceId })
const { data, error, mutate } = useSWR<
{ typebots: TypebotInDashboard[] },
Error
>(workspaceId ? `/api/typebots?${params}` : null, fetcher, {
dedupingInterval: env('E2E_TEST') === 'true' ? 0 : undefined,
})
if (error) onError(error)
return {
typebots: data?.typebots,
isLoading: !error && !data,
mutate,
}
}
export const createTypebot = async ({
folderId,
workspaceId,
}: Pick<Typebot, 'folderId' | 'workspaceId'>) => {
const typebot = {
folderId,
name: 'My typebot',
workspaceId,
}
return sendRequest<Typebot>({
url: `/api/typebots`,
method: 'POST',
body: typebot,
})
}
export const importTypebot = async (typebot: Typebot, userPlan: Plan) => {
const { typebot: newTypebot, webhookIdsMapping } = duplicateTypebot(
typebot,
userPlan
)
const { data, error } = await sendRequest<Typebot>({
url: `/api/typebots`,
method: 'POST',
body: newTypebot,
})
if (!data) return { data, error }
const webhookBlocks = typebot.groups
.flatMap((b) => b.blocks)
.filter(isWebhookBlock)
await Promise.all(
webhookBlocks.map((s) =>
duplicateWebhook(
newTypebot.id,
s.webhookId,
webhookIdsMapping.get(s.webhookId) as string
)
)
)
return { data, error }
}
const duplicateTypebot = (
typebot: Typebot,
userPlan: Plan
): { typebot: Typebot; webhookIdsMapping: Map<string, string> } => {
const groupIdsMapping = generateOldNewIdsMapping(typebot.groups)
const edgeIdsMapping = generateOldNewIdsMapping(typebot.edges)
const webhookIdsMapping = generateOldNewIdsMapping(
typebot.groups
.flatMap((b) => b.blocks)
.filter(isWebhookBlock)
.map((s) => ({ id: s.webhookId }))
)
const id = cuid()
return {
typebot: {
...typebot,
id,
name: `${typebot.name} copy`,
publishedTypebotId: null,
publicId: null,
customDomain: null,
groups: typebot.groups.map((b) => ({
...b,
id: groupIdsMapping.get(b.id) as string,
blocks: b.blocks.map((s) => {
const newIds = {
groupId: groupIdsMapping.get(s.groupId) as string,
outgoingEdgeId: s.outgoingEdgeId
? edgeIdsMapping.get(s.outgoingEdgeId)
: undefined,
}
if (
s.type === LogicBlockType.TYPEBOT_LINK &&
s.options.typebotId === 'current' &&
isDefined(s.options.groupId)
)
return {
...s,
options: {
...s.options,
groupId: groupIdsMapping.get(s.options.groupId as string),
},
}
if (blockHasItems(s))
return {
...s,
items: s.items.map((item) => ({
...item,
outgoingEdgeId: item.outgoingEdgeId
? (edgeIdsMapping.get(item.outgoingEdgeId) as string)
: undefined,
})),
...newIds,
} as ChoiceInputBlock | ConditionBlock
if (isWebhookBlock(s)) {
return {
...s,
webhookId: webhookIdsMapping.get(s.webhookId) as string,
...newIds,
}
}
return {
...s,
...newIds,
}
}),
})),
edges: typebot.edges.map((e) => ({
...e,
id: edgeIdsMapping.get(e.id) as string,
from: {
...e.from,
groupId: groupIdsMapping.get(e.from.groupId) as string,
},
to: { ...e.to, groupId: groupIdsMapping.get(e.to.groupId) as string },
})),
settings:
typebot.settings.general.isBrandingEnabled === false &&
userPlan === Plan.FREE
? {
...typebot.settings,
general: { ...typebot.settings.general, isBrandingEnabled: true },
}
: typebot.settings,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
resultsTablePreferences: typebot.resultsTablePreferences ?? undefined,
},
webhookIdsMapping,
}
}
const generateOldNewIdsMapping = (itemWithId: { id: string }[]) => {
const idsMapping: Map<string, string> = new Map()
itemWithId.forEach((item) => idsMapping.set(item.id, cuid()))
return idsMapping
}
export const getTypebot = (typebotId: string) =>
sendRequest<{ typebot: Typebot }>({
url: `/api/typebots/${typebotId}`,
method: 'GET',
})
export const deleteTypebot = async (id: string) =>
sendRequest({
url: `/api/typebots/${id}`,
method: 'DELETE',
})
export const updateTypebot = async (id: string, typebot: Typebot) =>
sendRequest({
url: `/api/typebots/${id}`,
method: 'PUT',
body: typebot,
})
export const patchTypebot = async (id: string, typebot: Partial<Typebot>) =>
sendRequest({
url: `/api/typebots/${id}`,
method: 'PATCH',
body: typebot,
})
export const parseNewBlock = (
type: DraggableBlockType,
groupId: string
): DraggableBlock => {
const id = cuid()
return {
id,
groupId,
type,
content: isBubbleBlockType(type) ? parseDefaultContent(type) : undefined,
options: blockTypeHasOption(type)
? parseDefaultBlockOptions(type)
: undefined,
webhookId: blockTypeHasWebhook(type) ? cuid() : undefined,
items: blockTypeHasItems(type) ? parseDefaultItems(type, id) : undefined,
} as DraggableBlock
}
const parseDefaultItems = (
type: LogicBlockType.CONDITION | InputBlockType.CHOICE,
blockId: string
): Item[] => {
switch (type) {
case InputBlockType.CHOICE:
return [{ id: cuid(), blockId, type: ItemType.BUTTON }]
case LogicBlockType.CONDITION:
return [
{
id: cuid(),
blockId,
type: ItemType.CONDITION,
content: defaultConditionContent,
},
]
}
}
const parseDefaultContent = (type: BubbleBlockType): BubbleBlockContent => {
switch (type) {
case BubbleBlockType.TEXT:
return defaultTextBubbleContent
case BubbleBlockType.IMAGE:
return defaultImageBubbleContent
case BubbleBlockType.VIDEO:
return defaultVideoBubbleContent
case BubbleBlockType.EMBED:
return defaultEmbedBubbleContent
}
}
const parseDefaultBlockOptions = (type: BlockWithOptionsType): BlockOptions => {
switch (type) {
case InputBlockType.TEXT:
return defaultTextInputOptions
case InputBlockType.NUMBER:
return defaultNumberInputOptions
case InputBlockType.EMAIL:
return defaultEmailInputOptions
case InputBlockType.DATE:
return defaultDateInputOptions
case InputBlockType.PHONE:
return defaultPhoneInputOptions
case InputBlockType.URL:
return defaultUrlInputOptions
case InputBlockType.CHOICE:
return defaultChoiceInputOptions
case InputBlockType.PAYMENT:
return defaultPaymentInputOptions
case InputBlockType.RATING:
return defaultRatingInputOptions
case InputBlockType.FILE:
return defaultFileInputOptions
case LogicBlockType.SET_VARIABLE:
return defaultSetVariablesOptions
case LogicBlockType.REDIRECT:
return defaultRedirectOptions
case LogicBlockType.CODE:
return defaultCodeOptions
case LogicBlockType.TYPEBOT_LINK:
return {}
case IntegrationBlockType.GOOGLE_SHEETS:
return defaultGoogleSheetsOptions
case IntegrationBlockType.GOOGLE_ANALYTICS:
return defaultGoogleAnalyticsOptions
case IntegrationBlockType.ZAPIER:
case IntegrationBlockType.PABBLY_CONNECT:
case IntegrationBlockType.MAKE_COM:
case IntegrationBlockType.WEBHOOK:
return defaultWebhookOptions
case IntegrationBlockType.EMAIL:
return defaultSendEmailOptions
case IntegrationBlockType.CHATWOOT:
return defaultChatwootOptions
}
}
export const checkIfTypebotsAreEqual = (typebotA: Typebot, typebotB: Typebot) =>
dequal(
JSON.parse(JSON.stringify(omit(typebotA, 'updatedAt'))),
JSON.parse(JSON.stringify(omit(typebotB, 'updatedAt')))
)
export const checkIfPublished = (
typebot: Typebot,
publicTypebot: PublicTypebot,
debug?: boolean
) => {
if (debug)
console.log(
diff(
JSON.parse(JSON.stringify(typebot.groups)),
JSON.parse(JSON.stringify(publicTypebot.groups))
)
)
return (
dequal(
JSON.parse(JSON.stringify(typebot.groups)),
JSON.parse(JSON.stringify(publicTypebot.groups))
) &&
dequal(
JSON.parse(JSON.stringify(typebot.settings)),
JSON.parse(JSON.stringify(publicTypebot.settings))
) &&
dequal(
JSON.parse(JSON.stringify(typebot.theme)),
JSON.parse(JSON.stringify(publicTypebot.theme))
) &&
dequal(
JSON.parse(JSON.stringify(typebot.variables)),
JSON.parse(JSON.stringify(publicTypebot.variables))
)
)
}
export const parseDefaultPublicId = (name: string, id: string) =>
toKebabCase(name) + `-${id?.slice(-7)}`
export const parseNewTypebot = ({
folderId,
name,
ownerAvatarUrl,
workspaceId,
isBrandingEnabled = true,
}: {
folderId: string | null
workspaceId: string
name: string
ownerAvatarUrl?: string
isBrandingEnabled?: boolean
}): Omit<
Typebot,
| 'createdAt'
| 'updatedAt'
| 'id'
| 'publishedTypebotId'
| 'publicId'
| 'customDomain'
| 'icon'
| 'isArchived'
| 'isClosed'
> => {
const startGroupId = cuid()
const startBlockId = cuid()
const startBlock: StartBlock = {
groupId: startGroupId,
id: startBlockId,
label: 'Start',
type: 'start',
}
const startGroup: Group = {
id: startGroupId,
title: 'Start',
graphCoordinates: { x: 0, y: 0 },
blocks: [startBlock],
}
return {
folderId,
name,
workspaceId,
groups: [startGroup],
edges: [],
variables: [],
theme: {
...defaultTheme,
chat: {
...defaultTheme.chat,
hostAvatar: { isEnabled: true, url: ownerAvatarUrl },
},
},
settings: {
...defaultSettings,
general: {
...defaultSettings.general,
isBrandingEnabled,
},
},
}
}
export const hasDefaultConnector = (block: Block) =>
!isChoiceInput(block) && !isConditionBlock(block)

View File

@ -1,58 +0,0 @@
import { ApiToken } from 'db'
import { fetcher } from 'services/utils'
import useSWR, { KeyedMutator } from 'swr'
import { env, sendRequest } from 'utils'
export type ApiTokenFromServer = { id: string; name: string; createdAt: string }
type ReturnedProps = {
apiTokens?: ApiTokenFromServer[]
isLoading: boolean
mutate: KeyedMutator<ServerResponse>
}
type ServerResponse = {
apiTokens: ApiTokenFromServer[]
}
export const useApiTokens = ({
userId,
onError,
}: {
userId?: string
onError: (error: Error) => void
}): ReturnedProps => {
const { data, error, mutate } = useSWR<ServerResponse, Error>(
userId ? `/api/users/${userId}/api-tokens` : null,
fetcher,
{
dedupingInterval: env('E2E_TEST') === 'true' ? 0 : undefined,
}
)
if (error) onError(error)
return {
apiTokens: data?.apiTokens,
isLoading: !error && !data,
mutate,
}
}
export const createApiToken = (userId: string, { name }: { name: string }) =>
sendRequest<{ apiToken: ApiTokenFromServer & { token: string } }>({
url: `/api/users/${userId}/api-tokens`,
method: 'POST',
body: {
name,
},
})
export const deleteApiToken = ({
userId,
tokenId,
}: {
userId: string
tokenId: string
}) =>
sendRequest<{ apiToken: ApiToken }>({
url: `/api/users/${userId}/api-tokens/${tokenId}`,
method: 'DELETE',
})

View File

@ -1,3 +0,0 @@
export * from './user'
export * from '../customDomains'
export * from '../credentials'

View File

@ -1,3 +0,0 @@
export * from './utils'
export * from './useUndo'
export * from './useRefState'

View File

@ -1,10 +0,0 @@
import { useEffect, useRef, useState } from 'react'
export const useRefState = (initialValue: string) => {
const [state, setState] = useState(initialValue)
const stateRef = useRef<string>(state)
useEffect(() => {
stateRef.current = state
}, [state])
return [stateRef, setState]
}

View File

@ -1,3 +0,0 @@
export * from './workspace'
export * from './member'
export * from './invitation'

View File

@ -1,28 +0,0 @@
import { WorkspaceInvitation } from 'db'
import { sendRequest } from 'utils'
import { Member } from './member'
export const sendInvitation = (
invitation: Omit<WorkspaceInvitation, 'id' | 'createdAt'>
) =>
sendRequest<{ invitation?: WorkspaceInvitation; member?: Member }>({
url: `/api/workspaces/${invitation.workspaceId}/invitations`,
method: 'POST',
body: invitation,
})
export const updateInvitation = (invitation: Partial<WorkspaceInvitation>) =>
sendRequest({
url: `/api/workspaces/${invitation.workspaceId}/invitations/${invitation.id}`,
method: 'PATCH',
body: invitation,
})
export const deleteInvitation = (invitation: {
workspaceId: string
id: string
}) =>
sendRequest({
url: `/api/workspaces/${invitation.workspaceId}/invitations/${invitation.id}`,
method: 'DELETE',
})

View File

@ -1,41 +0,0 @@
import { MemberInWorkspace, WorkspaceInvitation } from 'db'
import { fetcher } from 'services/utils'
import useSWR from 'swr'
import { env, sendRequest } from 'utils'
export type Member = MemberInWorkspace & {
name: string | null
image: string | null
email: string | null
}
export const useMembers = ({ workspaceId }: { workspaceId?: string }) => {
const { data, error, mutate } = useSWR<
{ members: Member[]; invitations: WorkspaceInvitation[] },
Error
>(workspaceId ? `/api/workspaces/${workspaceId}/members` : null, fetcher, {
dedupingInterval: env('E2E_TEST') === 'true' ? 0 : undefined,
})
return {
members: data?.members ?? [],
invitations: data?.invitations ?? [],
isLoading: !error && !data,
mutate,
}
}
export const updateMember = (
workspaceId: string,
member: Partial<MemberInWorkspace>
) =>
sendRequest({
method: 'PATCH',
url: `/api/workspaces/${workspaceId}/members/${member.userId}`,
body: member,
})
export const deleteMember = (workspaceId: string, userId: string) =>
sendRequest({
method: 'DELETE',
url: `/api/workspaces/${workspaceId}/members/${userId}`,
})

View File

@ -1,85 +0,0 @@
import { WorkspaceWithMembers } from 'contexts/WorkspaceContext'
import { Plan, Workspace } from 'db'
import useSWR from 'swr'
import { isDefined, isNotDefined, sendRequest } from 'utils'
import { fetcher } from '../utils'
export const useWorkspaces = ({ userId }: { userId?: string }) => {
const { data, error, mutate } = useSWR<
{
workspaces: WorkspaceWithMembers[]
},
Error
>(userId ? `/api/workspaces` : null, fetcher)
return {
workspaces: data?.workspaces,
isLoading: !error && !data,
mutate,
}
}
export const createNewWorkspace = async (
body: Omit<
Workspace,
| 'id'
| 'icon'
| 'createdAt'
| 'stripeId'
| 'additionalChatsIndex'
| 'additionalStorageIndex'
| 'chatsLimitFirstEmailSentAt'
| 'chatsLimitSecondEmailSentAt'
| 'storageLimitFirstEmailSentAt'
| 'storageLimitSecondEmailSentAt'
| 'customChatsLimit'
| 'customStorageLimit'
| 'customSeatsLimit'
>
) =>
sendRequest<{
workspace: Workspace
}>({
url: `/api/workspaces`,
method: 'POST',
body,
})
export const updateWorkspace = async (updates: Partial<Workspace>) =>
sendRequest<{
workspace: Workspace
}>({
url: `/api/workspaces/${updates.id}`,
method: 'PATCH',
body: updates,
})
export const deleteWorkspace = (workspaceId: string) =>
sendRequest<{
workspace: Workspace
}>({
url: `/api/workspaces/${workspaceId}`,
method: 'DELETE',
})
export const planToReadable = (plan?: Plan) => {
if (!plan) return
switch (plan) {
case Plan.FREE:
return 'Free'
case Plan.LIFETIME:
return 'Lifetime'
case Plan.OFFERED:
return 'Offered'
case Plan.PRO:
return 'Pro'
}
}
export const isFreePlan = (workspace?: Pick<Workspace, 'plan'>) =>
isNotDefined(workspace) || workspace?.plan === Plan.FREE
export const isWorkspaceProPlan = (workspace?: Pick<Workspace, 'plan'>) =>
isDefined(workspace) &&
(workspace.plan === Plan.PRO ||
workspace.plan === Plan.LIFETIME ||
workspace.plan === Plan.CUSTOM)

View File

@ -0,0 +1,8 @@
import { AlertProps, Alert, AlertIcon } from '@chakra-ui/react'
export const AlertInfo = (props: AlertProps) => (
<Alert status="info" bgColor={'blue.50'} rounded="md" {...props}>
<AlertIcon />
{props.children}
</Alert>
)

View File

@ -8,7 +8,7 @@ import { html } from '@codemirror/lang-html'
import { useEffect, useRef, useState } from 'react'
import { useDebouncedCallback } from 'use-debounce'
import { linter, LintSource } from '@codemirror/lint'
import { VariablesButton } from './buttons/VariablesButton'
import { VariablesButton } from '@/features/variables'
import { Variable } from 'models'
import { env } from 'utils'

View File

@ -9,7 +9,7 @@ import {
Portal,
Stack,
} from '@chakra-ui/react'
import { ChevronLeftIcon } from 'assets/icons'
import { ChevronLeftIcon } from '@/components/icons'
import React, { ReactNode } from 'react'
type Props<T> = {

View File

@ -1,4 +1,4 @@
import { ToolIcon } from 'assets/icons'
import { ToolIcon } from '@/components/icons'
import React from 'react'
import { chakra, IconProps, Image } from '@chakra-ui/react'

View File

@ -1,10 +1,10 @@
import { Flex, Stack, Text } from '@chakra-ui/react'
import { GiphyFetch } from '@giphy/js-fetch-api'
import { Grid } from '@giphy/react-components'
import { GiphyLogo } from 'assets/logos'
import { GiphyLogo } from './GiphyLogo'
import React, { useState } from 'react'
import { env, isEmpty } from 'utils'
import { Input } from '../Textbox'
import { Input } from '../inputs'
type GiphySearchFormProps = {
onSubmit: (url: string) => void

View File

@ -1,8 +1,8 @@
import { useState } from 'react'
import { Button, Flex, HStack, Stack } from '@chakra-ui/react'
import { UploadButton } from '../buttons/UploadButton'
import { UploadButton } from './UploadButton'
import { GiphySearchForm } from './GiphySearchForm'
import { Input } from '../Textbox/Input'
import { Input } from '../inputs/Input'
import { EmojiSearchableList } from './emoji/EmojiSearchableList'
type Props = {

View File

@ -1,6 +1,6 @@
import { compressFile } from '@/utils/helpers'
import { Button, ButtonProps, chakra } from '@chakra-ui/react'
import React, { ChangeEvent, useState } from 'react'
import { compressFile } from 'services/utils'
import { ChangeEvent, useState } from 'react'
import { uploadFiles } from 'utils'
type UploadButtonProps = {

View File

@ -1,6 +1,5 @@
import { Tooltip, chakra } from '@chakra-ui/react'
import { HelpCircleIcon } from 'assets/icons'
import React from 'react'
import { HelpCircleIcon } from './icons'
type Props = {
children: React.ReactNode

View File

@ -14,7 +14,7 @@ import { Variable } from 'models'
import { useState, useRef, useEffect, ChangeEvent } from 'react'
import { useDebouncedCallback } from 'use-debounce'
import { env, isDefined } from 'utils'
import { VariablesButton } from './buttons/VariablesButton'
import { VariablesButton } from '@/features/variables'
type Props = {
selectedItem?: string

View File

@ -1,10 +1,10 @@
import { useTypebot } from 'contexts/TypebotContext'
import { useUser } from 'contexts/UserContext'
import { useWorkspace } from 'contexts/WorkspaceContext'
import { useTypebot } from '@/features/editor'
import { useUser } from '@/features/account'
import { useWorkspace } from '@/features/workspace'
import React, { useEffect, useState } from 'react'
import { isCloudProdInstance } from 'services/utils'
import { planToReadable } from 'services/workspace'
import { initBubble } from 'typebot-js'
import { isCloudProdInstance } from '@/utils/helpers'
import { planToReadable } from '@/features/billing'
export const SupportBubble = () => {
const { typebot } = useTypebot()

View File

@ -1,5 +1,5 @@
import { Box, Button, Fade, Flex, IconButton, Stack } from '@chakra-ui/react'
import { TrashIcon, PlusIcon } from 'assets/icons'
import { TrashIcon, PlusIcon } from '@/components/icons'
import cuid from 'cuid'
import React, { useState } from 'react'

View File

@ -1,7 +1,7 @@
import Link, { LinkProps } from 'next/link'
import React from 'react'
import { chakra, HStack, TextProps } from '@chakra-ui/react'
import { ExternalLinkIcon } from 'assets/icons'
import { ExternalLinkIcon } from '@/components/icons'
type TextLinkProps = LinkProps & TextProps & { isExternal?: boolean }

View File

@ -8,21 +8,9 @@ import {
useDisclosure,
} from '@chakra-ui/react'
import React from 'react'
import { ChangePlanModal } from './modals/ChangePlanModal'
import { LimitReached } from './modals/ChangePlanModal'
import { ChangePlanModal, LimitReached } from '@/features/billing'
export const Info = (props: AlertProps) => (
<Alert status="info" bgColor={'blue.50'} rounded="md" {...props}>
<AlertIcon />
{props.children}
</Alert>
)
export const PublishFirstInfo = (props: AlertProps) => (
<Info {...props}>You need to publish your typebot first</Info>
)
export const UnlockPlanInfo = ({
export const UnlockPlanAlertInfo = ({
contentLabel,
buttonLabel = 'More info',
type,

View File

@ -11,8 +11,8 @@ import {
IconButton,
HStack,
} from '@chakra-ui/react'
import { EditIcon, PlusIcon, TrashIcon } from 'assets/icons'
import { useTypebot } from 'contexts/TypebotContext'
import { EditIcon, PlusIcon, TrashIcon } from '@/components/icons'
import { useTypebot } from '@/features/editor'
import cuid from 'cuid'
import { Variable } from 'models'
import React, { useState, useRef, ChangeEvent, useEffect } from 'react'

View File

@ -10,7 +10,7 @@ import { Variable } from 'models'
import React, { ChangeEvent, useEffect, useRef, useState } from 'react'
import { useDebouncedCallback } from 'use-debounce'
import { env } from 'utils'
import { VariablesButton } from '../buttons/VariablesButton'
import { VariablesButton } from '../../features/variables/components/VariablesButton'
import { MoreInfoTooltip } from '../MoreInfoTooltip'
export type TextBoxProps = {

View File

@ -1,2 +1,3 @@
export { Input } from './Input'
export { Textarea } from './Textarea'
export { SmartNumberInput } from './SmartNumberInput'

View File

@ -9,11 +9,11 @@ import {
useState,
} from 'react'
import { isDefined, isNotDefined } from 'utils'
import { updateUser as updateUserInDb } from 'services/user/user'
import { dequal } from 'dequal'
import { User } from 'db'
import { setUser as setSentryUser } from '@sentry/nextjs'
import { useToast } from 'components/shared/hooks/useToast'
import { useToast } from '@/hooks/useToast'
import { updateUserQuery } from './queries/updateUserQuery'
const userContext = createContext<{
user?: User
@ -28,7 +28,7 @@ const userContext = createContext<{
//@ts-ignore
}>({})
export const UserContext = ({ children }: { children: ReactNode }) => {
export const UserProvider = ({ children }: { children: ReactNode }) => {
const router = useRouter()
const { data: session, status } = useSession()
const [user, setUser] = useState<User | undefined>()
@ -84,7 +84,7 @@ export const UserContext = ({ children }: { children: ReactNode }) => {
if (isNotDefined(user)) return
setIsSaving(true)
if (newUser) updateUser(newUser)
const { error } = await updateUserInDb(user.id, { ...user, ...newUser })
const { error } = await updateUserQuery(user.id, { ...user, ...newUser })
if (error) showToast({ title: error.name, description: error.message })
await refreshUser()
setIsSaving(false)

View File

@ -1,5 +1,5 @@
import { getTestAsset } from '@/test/utils/playwright'
import test, { expect } from '@playwright/test'
import path from 'path'
import { userId } from 'utils/playwright/databaseSetup'
test.describe.configure({ mode: 'parallel' })
@ -14,10 +14,7 @@ test('should display user info properly', async ({ page }) => {
).toBeDefined()
await page.fill('#name', 'John Doe')
expect(saveButton).toBeVisible()
await page.setInputFiles(
'input[type="file"]',
path.join(__dirname, '../fixtures/avatar.jpg')
)
await page.setInputFiles('input[type="file"]', getTestAsset('avatar.jpg'))
await expect(page.locator('img >> nth=1')).toHaveAttribute(
'src',
new RegExp(

View File

@ -15,18 +15,16 @@ import {
Flex,
useDisclosure,
} from '@chakra-ui/react'
import { ConfirmModal } from 'components/modals/ConfirmModal'
import { useToast } from 'components/shared/hooks/useToast'
import { ConfirmModal } from '@/components/ConfirmModal'
import { useToast } from '@/hooks/useToast'
import { User } from 'db'
import React, { useState } from 'react'
import {
ApiTokenFromServer,
deleteApiToken,
useApiTokens,
} from 'services/user/apiTokens'
import { timeSince } from 'services/utils'
import { byId, isDefined } from 'utils'
import { CreateTokenModal } from './CreateTokenModal'
import { useApiTokens } from '../../../hooks/useApiTokens'
import { ApiTokenFromServer } from '../../../types'
import { timeSince } from '@/utils/helpers'
import { deleteApiTokenQuery } from '../../../queries/deleteApiTokenQuery'
type Props = { user: User }
@ -51,7 +49,7 @@ export const ApiTokensList = ({ user }: Props) => {
const deleteToken = async (tokenId?: string) => {
if (!apiTokens || !tokenId) return
const { error } = await deleteApiToken({ userId: user.id, tokenId })
const { error } = await deleteApiTokenQuery({ userId: user.id, tokenId })
if (!error) mutate({ apiTokens: apiTokens.filter((t) => t.id !== tokenId) })
}

View File

@ -1,3 +1,6 @@
import { CopyButton } from '@/components/CopyButton'
import { createApiTokenQuery } from '../../../queries/createApiTokenQuery'
import { ApiTokenFromServer } from '../../../types'
import {
Modal,
ModalOverlay,
@ -13,9 +16,7 @@ import {
InputGroup,
InputRightElement,
} from '@chakra-ui/react'
import { CopyButton } from 'components/shared/buttons/CopyButton'
import React, { FormEvent, useState } from 'react'
import { ApiTokenFromServer, createApiToken } from 'services/user/apiTokens'
type Props = {
userId: string
@ -37,7 +38,7 @@ export const CreateTokenModal = ({
const createToken = async (e: FormEvent) => {
e.preventDefault()
setIsSubmitting(true)
const { data } = await createApiToken(userId, { name })
const { data } = await createApiTokenQuery(userId, { name })
if (data?.apiToken) {
setNewTokenValue(data.apiToken.token)
onNewToken(data.apiToken)

View File

@ -10,12 +10,12 @@ import {
Flex,
Text,
} from '@chakra-ui/react'
import { UploadIcon } from 'assets/icons'
import { UploadButton } from 'components/shared/buttons/UploadButton'
import { useUser } from 'contexts/UserContext'
import { UploadIcon } from '@/components/icons'
import React, { ChangeEvent, useState } from 'react'
import { isDefined } from 'utils'
import { ApiTokensList } from './ApiTokensList'
import { UploadButton } from '@/components/ImageUploadContent/UploadButton'
import { useUser } from '@/features/account'
export const MyAccountForm = () => {
const {

View File

@ -0,0 +1,30 @@
import { fetcher } from '@/utils/helpers'
import useSWR from 'swr'
import { env } from 'utils'
import { ApiTokenFromServer } from '../types'
type ServerResponse = {
apiTokens: ApiTokenFromServer[]
}
export const useApiTokens = ({
userId,
onError,
}: {
userId?: string
onError: (error: Error) => void
}) => {
const { data, error, mutate } = useSWR<ServerResponse, Error>(
userId ? `/api/users/${userId}/api-tokens` : null,
fetcher,
{
dedupingInterval: env('E2E_TEST') === 'true' ? 0 : undefined,
}
)
if (error) onError(error)
return {
apiTokens: data?.apiTokens,
isLoading: !error && !data,
mutate,
}
}

View File

@ -0,0 +1,3 @@
export { UserProvider, useUser } from './UserProvider'
export type { ApiTokenFromServer } from './types'
export { MyAccountForm } from './components/MyAccountForm'

Some files were not shown because too many files have changed in this diff Show More