feat: ✨ Add custom domains
This commit is contained in:
@ -49,3 +49,8 @@ NEXT_PUBLIC_VIEWER_HOST=http://localhost:3001
|
|||||||
|
|
||||||
# (Optional) Error tracking with Sentry
|
# (Optional) Error tracking with Sentry
|
||||||
NEXT_PUBLIC_SENTRY_DSN=
|
NEXT_PUBLIC_SENTRY_DSN=
|
||||||
|
|
||||||
|
# Vercel
|
||||||
|
VERCEL_TOKEN=
|
||||||
|
VERCEL_VIEWER_PROJECT_NAME=
|
||||||
|
VERCEL_TEAM_ID=
|
@ -14,23 +14,25 @@ import { CopyButton } from 'components/shared/buttons/CopyButton'
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
type EditableUrlProps = {
|
type EditableUrlProps = {
|
||||||
publicId?: string
|
hostname: string
|
||||||
onPublicIdChange: (publicId: string) => void
|
pathname?: string
|
||||||
|
onPathnameChange: (pathname: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EditableUrl = ({
|
export const EditableUrl = ({
|
||||||
publicId,
|
hostname,
|
||||||
onPublicIdChange,
|
pathname,
|
||||||
|
onPathnameChange,
|
||||||
}: EditableUrlProps) => {
|
}: EditableUrlProps) => {
|
||||||
return (
|
return (
|
||||||
<Editable
|
<Editable
|
||||||
as={HStack}
|
as={HStack}
|
||||||
spacing={3}
|
spacing={3}
|
||||||
defaultValue={publicId}
|
defaultValue={pathname}
|
||||||
onSubmit={onPublicIdChange}
|
onSubmit={onPathnameChange}
|
||||||
>
|
>
|
||||||
<HStack spacing={1}>
|
<HStack spacing={1}>
|
||||||
<Text>{process.env.NEXT_PUBLIC_VIEWER_HOST}/</Text>
|
<Text>{hostname}/</Text>
|
||||||
<Tooltip label="Edit">
|
<Tooltip label="Edit">
|
||||||
<EditablePreview
|
<EditablePreview
|
||||||
mx={1}
|
mx={1}
|
||||||
@ -48,10 +50,7 @@ export const EditableUrl = ({
|
|||||||
|
|
||||||
<HStack>
|
<HStack>
|
||||||
<EditButton size="xs" />
|
<EditButton size="xs" />
|
||||||
<CopyButton
|
<CopyButton size="xs" textToCopy={`${hostname}/${pathname}`} />
|
||||||
size="xs"
|
|
||||||
textToCopy={`${process.env.NEXT_PUBLIC_VIEWER_HOST}/${publicId}`}
|
|
||||||
/>
|
|
||||||
</HStack>
|
</HStack>
|
||||||
</Editable>
|
</Editable>
|
||||||
)
|
)
|
||||||
|
@ -1,16 +1,38 @@
|
|||||||
import { Flex, Heading, Stack, Wrap } from '@chakra-ui/react'
|
import {
|
||||||
|
Flex,
|
||||||
|
Heading,
|
||||||
|
HStack,
|
||||||
|
IconButton,
|
||||||
|
Stack,
|
||||||
|
Tag,
|
||||||
|
useToast,
|
||||||
|
Wrap,
|
||||||
|
Text,
|
||||||
|
} from '@chakra-ui/react'
|
||||||
|
import { TrashIcon } from 'assets/icons'
|
||||||
|
import { UpgradeButton } from 'components/shared/buttons/UpgradeButton'
|
||||||
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
|
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
|
||||||
|
import { useUser } from 'contexts/UserContext'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { parseDefaultPublicId } from 'services/typebots'
|
import { parseDefaultPublicId } from 'services/typebots'
|
||||||
import { isDefined } from 'utils'
|
import { isFreePlan } from 'services/user'
|
||||||
|
import { isDefined, isNotDefined } from 'utils'
|
||||||
|
import { CustomDomainsDropdown } from './customDomain/CustomDomainsDropdown'
|
||||||
import { EditableUrl } from './EditableUrl'
|
import { EditableUrl } from './EditableUrl'
|
||||||
import { integrationsList } from './integrations/EmbedButton'
|
import { integrationsList } from './integrations/EmbedButton'
|
||||||
|
|
||||||
export const ShareContent = () => {
|
export const ShareContent = () => {
|
||||||
|
const { user } = useUser()
|
||||||
const { typebot, updateOnBothTypebots } = useTypebot()
|
const { typebot, updateOnBothTypebots } = useTypebot()
|
||||||
|
const toast = useToast({
|
||||||
|
position: 'top-right',
|
||||||
|
status: 'error',
|
||||||
|
})
|
||||||
|
|
||||||
const handlePublicIdChange = (publicId: string) => {
|
const handlePublicIdChange = (publicId: string) => {
|
||||||
if (publicId === typebot?.publicId) return
|
if (publicId === typebot?.publicId) return
|
||||||
|
if (publicId.length < 4)
|
||||||
|
return toast({ description: 'ID must be longer than 4 characters' })
|
||||||
updateOnBothTypebots({ publicId })
|
updateOnBothTypebots({ publicId })
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -19,19 +41,58 @@ export const ShareContent = () => {
|
|||||||
: ''
|
: ''
|
||||||
const isPublished = isDefined(typebot?.publishedTypebotId)
|
const isPublished = isDefined(typebot?.publishedTypebotId)
|
||||||
|
|
||||||
|
const handleCustomDomainChange = (customDomain: string | null) =>
|
||||||
|
updateOnBothTypebots({ customDomain })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex h="full" w="full" justifyContent="center" align="flex-start">
|
<Flex h="full" w="full" justifyContent="center" align="flex-start">
|
||||||
<Stack maxW="1000px" w="full" pt="10" spacing={10}>
|
<Stack maxW="1000px" w="full" pt="10" spacing={10}>
|
||||||
<Stack spacing={4}>
|
<Stack spacing={4} align="flex-start">
|
||||||
<Heading fontSize="2xl" as="h1">
|
<Heading fontSize="2xl" as="h1">
|
||||||
Your typebot link
|
Your typebot link
|
||||||
</Heading>
|
</Heading>
|
||||||
{typebot && (
|
{typebot && (
|
||||||
<EditableUrl
|
<EditableUrl
|
||||||
publicId={publicId}
|
hostname={
|
||||||
onPublicIdChange={handlePublicIdChange}
|
process.env.NEXT_PUBLIC_VIEWER_HOST ?? 'https://typebot.io'
|
||||||
|
}
|
||||||
|
pathname={publicId}
|
||||||
|
onPathnameChange={handlePublicIdChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{typebot?.customDomain && (
|
||||||
|
<HStack>
|
||||||
|
<EditableUrl
|
||||||
|
hostname={'https://' + typebot.customDomain.split('/')[0]}
|
||||||
|
pathname={typebot.customDomain.split('/')[1]}
|
||||||
|
onPathnameChange={(pathname) =>
|
||||||
|
handleCustomDomainChange(
|
||||||
|
typebot.customDomain?.split('/')[0] + pathname
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={<TrashIcon />}
|
||||||
|
aria-label="Remove custom domain"
|
||||||
|
size="xs"
|
||||||
|
onClick={() => handleCustomDomainChange(null)}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
{isFreePlan(user) ? (
|
||||||
|
<UpgradeButton colorScheme="gray">
|
||||||
|
<Text mr="2">Add my domain</Text>{' '}
|
||||||
|
<Tag colorScheme="orange">Pro</Tag>
|
||||||
|
</UpgradeButton>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{isNotDefined(typebot?.customDomain) && (
|
||||||
|
<CustomDomainsDropdown
|
||||||
|
onCustomDomainSelect={handleCustomDomainChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Stack spacing={4}>
|
<Stack spacing={4}>
|
||||||
|
189
apps/builder/components/share/customDomain/CustomDomainModal.tsx
Normal file
189
apps/builder/components/share/customDomain/CustomDomainModal.tsx
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
ModalOverlay,
|
||||||
|
ModalContent,
|
||||||
|
ModalHeader,
|
||||||
|
Heading,
|
||||||
|
ModalCloseButton,
|
||||||
|
ModalBody,
|
||||||
|
Stack,
|
||||||
|
Input,
|
||||||
|
HStack,
|
||||||
|
Alert,
|
||||||
|
ModalFooter,
|
||||||
|
Button,
|
||||||
|
useToast,
|
||||||
|
Text,
|
||||||
|
Tooltip,
|
||||||
|
} from '@chakra-ui/react'
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { createCustomDomain } from 'services/customDomains'
|
||||||
|
|
||||||
|
const hostnameRegex =
|
||||||
|
/^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])$/
|
||||||
|
|
||||||
|
type CustomDomainModalProps = {
|
||||||
|
userId: string
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
domain?: string
|
||||||
|
onNewDomain: (customDomain: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CustomDomainModal = ({
|
||||||
|
userId,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onNewDomain,
|
||||||
|
domain = '',
|
||||||
|
}: CustomDomainModalProps) => {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [inputValue, setInputValue] = useState(domain)
|
||||||
|
const [hostname, setHostname] = useState({
|
||||||
|
domain: splitHostname(domain)?.domain ?? '',
|
||||||
|
subdomain: splitHostname(domain)?.subdomain ?? '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const toast = useToast({
|
||||||
|
position: 'top-right',
|
||||||
|
status: 'error',
|
||||||
|
description: 'An error occured',
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (inputValue === '' || !isOpen) return
|
||||||
|
if (!hostnameRegex.test(inputValue))
|
||||||
|
return setHostname({ domain: '', subdomain: '' })
|
||||||
|
const hostnameDetails = splitHostname(inputValue)
|
||||||
|
if (!hostnameDetails) return setHostname({ domain: '', subdomain: '' })
|
||||||
|
setHostname({
|
||||||
|
domain: hostnameDetails.domain,
|
||||||
|
subdomain: hostnameDetails.subdomain,
|
||||||
|
})
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [inputValue])
|
||||||
|
|
||||||
|
const onAddDomainClick = async () => {
|
||||||
|
if (!hostnameRegex.test(inputValue)) return
|
||||||
|
setIsLoading(true)
|
||||||
|
const { error } = await createCustomDomain(userId, {
|
||||||
|
name: inputValue,
|
||||||
|
})
|
||||||
|
setIsLoading(false)
|
||||||
|
if (error) return toast({ title: error.name, description: error.message })
|
||||||
|
onNewDomain(inputValue)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
size="xl"
|
||||||
|
initialFocusRef={inputRef}
|
||||||
|
>
|
||||||
|
<ModalOverlay />
|
||||||
|
<ModalContent>
|
||||||
|
<ModalHeader>
|
||||||
|
<Heading size="md">Add a custom domain</Heading>
|
||||||
|
</ModalHeader>
|
||||||
|
<ModalCloseButton />
|
||||||
|
<ModalBody>
|
||||||
|
<Stack>
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
|
placeholder="bot.my-domain.com"
|
||||||
|
/>
|
||||||
|
{hostname.domain !== '' && (
|
||||||
|
<>
|
||||||
|
<Text>
|
||||||
|
Add the following record in your DNS provider to continue:
|
||||||
|
</Text>
|
||||||
|
{hostname.subdomain ? (
|
||||||
|
<HStack
|
||||||
|
bgColor="gray.700"
|
||||||
|
color="white"
|
||||||
|
rounded="md"
|
||||||
|
p={4}
|
||||||
|
spacing={8}
|
||||||
|
>
|
||||||
|
<Stack>
|
||||||
|
<Text fontWeight="bold">Type</Text>
|
||||||
|
<Text>CNAME</Text>
|
||||||
|
</Stack>
|
||||||
|
<Stack>
|
||||||
|
<Text fontWeight="bold">Name</Text>
|
||||||
|
<Text>{hostname.subdomain}</Text>
|
||||||
|
</Stack>
|
||||||
|
<Stack>
|
||||||
|
<Text fontWeight="bold">Value</Text>
|
||||||
|
<Text>viewer.typebot.io</Text>
|
||||||
|
</Stack>
|
||||||
|
</HStack>
|
||||||
|
) : (
|
||||||
|
<HStack
|
||||||
|
bgColor="gray.700"
|
||||||
|
color="white"
|
||||||
|
rounded="md"
|
||||||
|
p={4}
|
||||||
|
spacing={8}
|
||||||
|
>
|
||||||
|
<Stack>
|
||||||
|
<Text fontWeight="bold">Type</Text>
|
||||||
|
<Text>A</Text>
|
||||||
|
</Stack>
|
||||||
|
<Stack>
|
||||||
|
<Text fontWeight="bold">Name</Text>
|
||||||
|
<Text>*</Text>
|
||||||
|
</Stack>
|
||||||
|
<Stack>
|
||||||
|
<Text fontWeight="bold">Value</Text>
|
||||||
|
<Text>76.76.21.21</Text>
|
||||||
|
</Stack>
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
<Alert rounded="md">
|
||||||
|
Depending on your provider, it might take some time for the
|
||||||
|
changes to apply
|
||||||
|
</Alert>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter as={HStack}>
|
||||||
|
<Tooltip
|
||||||
|
label="Domain is invalid"
|
||||||
|
isDisabled={hostname.domain !== ''}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<Button
|
||||||
|
onClick={onAddDomainClick}
|
||||||
|
isDisabled={hostname.domain === ''}
|
||||||
|
isLoading={isLoading}
|
||||||
|
colorScheme="blue"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const splitHostname = (
|
||||||
|
hostname: string
|
||||||
|
): { domain: string; type: string; subdomain: string } | undefined => {
|
||||||
|
const urlParts = /([a-z-0-9]{2,63}).([a-z.]{2,5})$/.exec(hostname)
|
||||||
|
if (!urlParts) return
|
||||||
|
const [, domain, type] = urlParts
|
||||||
|
const subdomain = hostname.replace(`${domain}.${type}`, '').slice(0, -1)
|
||||||
|
return {
|
||||||
|
domain,
|
||||||
|
type,
|
||||||
|
subdomain,
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,133 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
IconButton,
|
||||||
|
Menu,
|
||||||
|
MenuButton,
|
||||||
|
MenuButtonProps,
|
||||||
|
MenuItem,
|
||||||
|
MenuList,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
useDisclosure,
|
||||||
|
useToast,
|
||||||
|
} from '@chakra-ui/react'
|
||||||
|
import { ChevronLeftIcon, PlusIcon, TrashIcon } from 'assets/icons'
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { useUser } from 'contexts/UserContext'
|
||||||
|
import { CustomDomainModal } from './CustomDomainModal'
|
||||||
|
import { deleteCustomDomain, useCustomDomains } from 'services/customDomains'
|
||||||
|
|
||||||
|
type Props = Omit<MenuButtonProps, 'type'> & {
|
||||||
|
currentCustomDomain?: string
|
||||||
|
onCustomDomainSelect: (domain: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CustomDomainsDropdown = ({
|
||||||
|
currentCustomDomain,
|
||||||
|
onCustomDomainSelect,
|
||||||
|
...props
|
||||||
|
}: Props) => {
|
||||||
|
const [isDeleting, setIsDeleting] = useState('')
|
||||||
|
const { isOpen, onOpen, onClose } = useDisclosure()
|
||||||
|
const { user } = useUser()
|
||||||
|
const { customDomains, mutate } = useCustomDomains({
|
||||||
|
userId: user?.id,
|
||||||
|
onError: (error) =>
|
||||||
|
toast({ title: error.name, description: error.message }),
|
||||||
|
})
|
||||||
|
const toast = useToast({
|
||||||
|
position: 'top-right',
|
||||||
|
status: 'error',
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleMenuItemClick = (customDomain: string) => () =>
|
||||||
|
onCustomDomainSelect(customDomain)
|
||||||
|
|
||||||
|
const handleDeleteDomainClick =
|
||||||
|
(domainName: string) => async (e: React.MouseEvent) => {
|
||||||
|
if (!user) return
|
||||||
|
e.stopPropagation()
|
||||||
|
setIsDeleting(domainName)
|
||||||
|
const { error } = await deleteCustomDomain(user.id, domainName)
|
||||||
|
setIsDeleting('')
|
||||||
|
if (error) return toast({ title: error.name, description: error.message })
|
||||||
|
mutate({
|
||||||
|
customDomains: (customDomains ?? []).filter(
|
||||||
|
(cd) => cd.name !== domainName
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNewDomain = (domain: string) => {
|
||||||
|
if (!user) return
|
||||||
|
mutate({
|
||||||
|
customDomains: [
|
||||||
|
...(customDomains ?? []),
|
||||||
|
{ name: domain, ownerId: user?.id },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
handleMenuItemClick(domain)()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu isLazy placement="bottom-start" matchWidth>
|
||||||
|
{user?.id && (
|
||||||
|
<CustomDomainModal
|
||||||
|
userId={user.id}
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
onNewDomain={handleNewDomain}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<MenuButton
|
||||||
|
as={Button}
|
||||||
|
rightIcon={<ChevronLeftIcon transform={'rotate(-90deg)'} />}
|
||||||
|
colorScheme="gray"
|
||||||
|
justifyContent="space-between"
|
||||||
|
textAlign="left"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Text isTruncated overflowY="visible" h="20px">
|
||||||
|
{currentCustomDomain ?? 'Add my domain'}
|
||||||
|
</Text>
|
||||||
|
</MenuButton>
|
||||||
|
<MenuList maxW="500px" shadow="lg">
|
||||||
|
<Stack maxH={'35vh'} overflowY="scroll" spacing="0">
|
||||||
|
{(customDomains ?? []).map((customDomain) => (
|
||||||
|
<Button
|
||||||
|
role="menuitem"
|
||||||
|
minH="40px"
|
||||||
|
key={customDomain.name}
|
||||||
|
onClick={handleMenuItemClick(customDomain.name)}
|
||||||
|
fontSize="16px"
|
||||||
|
fontWeight="normal"
|
||||||
|
rounded="none"
|
||||||
|
colorScheme="gray"
|
||||||
|
variant="ghost"
|
||||||
|
justifyContent="space-between"
|
||||||
|
>
|
||||||
|
{customDomain.name}
|
||||||
|
<IconButton
|
||||||
|
icon={<TrashIcon />}
|
||||||
|
aria-label="Remove domain"
|
||||||
|
size="xs"
|
||||||
|
onClick={handleDeleteDomainClick(customDomain.name)}
|
||||||
|
isLoading={isDeleting === customDomain.name}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
<MenuItem
|
||||||
|
maxW="500px"
|
||||||
|
overflow="hidden"
|
||||||
|
whiteSpace="nowrap"
|
||||||
|
textOverflow="ellipsis"
|
||||||
|
icon={<PlusIcon />}
|
||||||
|
onClick={onOpen}
|
||||||
|
>
|
||||||
|
Connect new
|
||||||
|
</MenuItem>
|
||||||
|
</Stack>
|
||||||
|
</MenuList>
|
||||||
|
</Menu>
|
||||||
|
)
|
||||||
|
}
|
@ -9,7 +9,7 @@ export const UpgradeButton = ({ type, ...props }: Props) => {
|
|||||||
const { isOpen, onOpen, onClose } = useDisclosure()
|
const { isOpen, onOpen, onClose } = useDisclosure()
|
||||||
return (
|
return (
|
||||||
<Button colorScheme="blue" {...props} onClick={onOpen}>
|
<Button colorScheme="blue" {...props} onClick={onOpen}>
|
||||||
Upgrade
|
{props.children ?? 'Upgrade'}
|
||||||
<UpgradeModal isOpen={isOpen} onClose={onClose} type={type} />
|
<UpgradeModal isOpen={isOpen} onClose={onClose} type={type} />
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
|
@ -60,6 +60,7 @@ const typebotContext = createContext<
|
|||||||
updateOnBothTypebots: (updates: {
|
updateOnBothTypebots: (updates: {
|
||||||
publicId?: string
|
publicId?: string
|
||||||
name?: string
|
name?: string
|
||||||
|
customDomain?: string | null
|
||||||
}) => void
|
}) => void
|
||||||
publishTypebot: () => void
|
publishTypebot: () => void
|
||||||
} & BlocksActions &
|
} & BlocksActions &
|
||||||
@ -144,8 +145,6 @@ export const TypebotContext = ({
|
|||||||
debounceTimeout: autoSaveTimeout,
|
debounceTimeout: autoSaveTimeout,
|
||||||
})
|
})
|
||||||
|
|
||||||
const [localPublishedTypebot, setLocalPublishedTypebot] =
|
|
||||||
useState<PublicTypebot>()
|
|
||||||
const [isSavingLoading, setIsSavingLoading] = useState(false)
|
const [isSavingLoading, setIsSavingLoading] = useState(false)
|
||||||
const [isPublishing, setIsPublishing] = useState(false)
|
const [isPublishing, setIsPublishing] = useState(false)
|
||||||
|
|
||||||
@ -177,7 +176,6 @@ export const TypebotContext = ({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
setLocalTypebot({ ...typebot })
|
setLocalTypebot({ ...typebot })
|
||||||
if (publishedTypebot) setLocalPublishedTypebot({ ...publishedTypebot })
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [isLoading])
|
}, [isLoading])
|
||||||
|
|
||||||
@ -206,25 +204,15 @@ export const TypebotContext = ({
|
|||||||
const updateLocalTypebot = (updates: UpdateTypebotPayload) =>
|
const updateLocalTypebot = (updates: UpdateTypebotPayload) =>
|
||||||
localTypebot && setLocalTypebot({ ...localTypebot, ...updates })
|
localTypebot && setLocalTypebot({ ...localTypebot, ...updates })
|
||||||
|
|
||||||
const updateLocalPublishedTypebot = (updates: UpdateTypebotPayload) =>
|
|
||||||
publishedTypebot &&
|
|
||||||
setLocalPublishedTypebot({
|
|
||||||
...localPublishedTypebot,
|
|
||||||
...(updates as PublicTypebot),
|
|
||||||
})
|
|
||||||
|
|
||||||
const publishTypebot = async () => {
|
const publishTypebot = async () => {
|
||||||
if (!localTypebot) return
|
if (!localTypebot) return
|
||||||
const publishedTypebotId = generate()
|
const publishedTypebotId = generate()
|
||||||
const newLocalTypebot = { ...localTypebot }
|
const newLocalTypebot = { ...localTypebot }
|
||||||
if (
|
if (publishedTypebot && isNotDefined(localTypebot.publishedTypebotId)) {
|
||||||
localPublishedTypebot &&
|
updateLocalTypebot({ publishedTypebotId: publishedTypebot.id })
|
||||||
isNotDefined(localTypebot.publishedTypebotId)
|
|
||||||
) {
|
|
||||||
updateLocalTypebot({ publishedTypebotId: localPublishedTypebot.id })
|
|
||||||
await saveTypebot()
|
await saveTypebot()
|
||||||
}
|
}
|
||||||
if (!localPublishedTypebot) {
|
if (!publishedTypebot) {
|
||||||
const newPublicId = parseDefaultPublicId(
|
const newPublicId = parseDefaultPublicId(
|
||||||
localTypebot.name,
|
localTypebot.name,
|
||||||
localTypebot.id
|
localTypebot.id
|
||||||
@ -233,10 +221,10 @@ export const TypebotContext = ({
|
|||||||
newLocalTypebot.publicId = newPublicId
|
newLocalTypebot.publicId = newPublicId
|
||||||
await saveTypebot()
|
await saveTypebot()
|
||||||
}
|
}
|
||||||
if (localPublishedTypebot) {
|
if (publishedTypebot) {
|
||||||
await savePublishedTypebot({
|
await savePublishedTypebot({
|
||||||
...parseTypebotToPublicTypebot(newLocalTypebot),
|
...parseTypebotToPublicTypebot(newLocalTypebot),
|
||||||
id: localPublishedTypebot.id,
|
id: publishedTypebot.id,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
setIsPublishing(true)
|
setIsPublishing(true)
|
||||||
@ -244,24 +232,23 @@ export const TypebotContext = ({
|
|||||||
...parseTypebotToPublicTypebot(newLocalTypebot),
|
...parseTypebotToPublicTypebot(newLocalTypebot),
|
||||||
id: publishedTypebotId,
|
id: publishedTypebotId,
|
||||||
})
|
})
|
||||||
setLocalPublishedTypebot(data)
|
|
||||||
setIsPublishing(false)
|
setIsPublishing(false)
|
||||||
if (error) return toast({ title: error.name, description: error.message })
|
if (error) return toast({ title: error.name, description: error.message })
|
||||||
mutate({ typebot: localTypebot })
|
mutate({ typebot: localTypebot, publishedTypebot: data })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateOnBothTypebots = async (updates: {
|
const updateOnBothTypebots = async (updates: {
|
||||||
publicId?: string
|
publicId?: string
|
||||||
name?: string
|
name?: string
|
||||||
|
customDomain?: string | null
|
||||||
}) => {
|
}) => {
|
||||||
updateLocalTypebot(updates)
|
updateLocalTypebot(updates)
|
||||||
await saveTypebot()
|
await saveTypebot()
|
||||||
if (!localPublishedTypebot) return
|
if (!publishedTypebot) return
|
||||||
updateLocalPublishedTypebot(updates)
|
|
||||||
await savePublishedTypebot({
|
await savePublishedTypebot({
|
||||||
...localPublishedTypebot,
|
...publishedTypebot,
|
||||||
...(updates as PublicTypebot),
|
...updates,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -269,7 +256,7 @@ export const TypebotContext = ({
|
|||||||
<typebotContext.Provider
|
<typebotContext.Provider
|
||||||
value={{
|
value={{
|
||||||
typebot: localTypebot,
|
typebot: localTypebot,
|
||||||
publishedTypebot: localPublishedTypebot,
|
publishedTypebot,
|
||||||
hasUnsavedChanges,
|
hasUnsavedChanges,
|
||||||
isSavingLoading,
|
isSavingLoading,
|
||||||
save: saveTypebot,
|
save: saveTypebot,
|
||||||
|
@ -37,11 +37,16 @@ export const UserContext = ({ children }: { children: ReactNode }) => {
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { data: session, status } = useSession()
|
const { data: session, status } = useSession()
|
||||||
const [user, setUser] = useState<User | undefined>()
|
const [user, setUser] = useState<User | undefined>()
|
||||||
const { credentials, mutate } = useCredentials({
|
const toast = useToast({
|
||||||
|
position: 'top-right',
|
||||||
|
status: 'error',
|
||||||
|
})
|
||||||
|
const { credentials, mutate: mutateCredentials } = useCredentials({
|
||||||
userId: user?.id,
|
userId: user?.id,
|
||||||
onError: (error) =>
|
onError: (error) =>
|
||||||
toast({ title: error.name, description: error.message }),
|
toast({ title: error.name, description: error.message }),
|
||||||
})
|
})
|
||||||
|
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
const isOAuthProvider = useMemo(
|
const isOAuthProvider = useMemo(
|
||||||
() => (session?.providerType as boolean | undefined) ?? false,
|
() => (session?.providerType as boolean | undefined) ?? false,
|
||||||
@ -53,11 +58,6 @@ export const UserContext = ({ children }: { children: ReactNode }) => {
|
|||||||
[session?.user, user]
|
[session?.user, user]
|
||||||
)
|
)
|
||||||
|
|
||||||
const toast = useToast({
|
|
||||||
position: 'top-right',
|
|
||||||
status: 'error',
|
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isDefined(user) || isNotDefined(session)) return
|
if (isDefined(user) || isNotDefined(session)) return
|
||||||
setUser(session.user as User)
|
setUser(session.user as User)
|
||||||
@ -92,14 +92,14 @@ export const UserContext = ({ children }: { children: ReactNode }) => {
|
|||||||
<userContext.Provider
|
<userContext.Provider
|
||||||
value={{
|
value={{
|
||||||
user,
|
user,
|
||||||
updateUser,
|
|
||||||
saveUser,
|
|
||||||
isSaving,
|
isSaving,
|
||||||
isLoading: status === 'loading',
|
isLoading: status === 'loading',
|
||||||
hasUnsavedChanges,
|
hasUnsavedChanges,
|
||||||
isOAuthProvider,
|
isOAuthProvider,
|
||||||
credentials: credentials ?? [],
|
credentials: credentials ?? [],
|
||||||
mutateCredentials: mutate,
|
mutateCredentials,
|
||||||
|
updateUser,
|
||||||
|
saveUser,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
51
apps/builder/pages/api/users/[id]/customDomains.ts
Normal file
51
apps/builder/pages/api/users/[id]/customDomains.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { withSentry } from '@sentry/nextjs'
|
||||||
|
import { CustomDomain, Prisma, User } from 'db'
|
||||||
|
import { got, HTTPError } from 'got'
|
||||||
|
import prisma from 'libs/prisma'
|
||||||
|
import { NextApiRequest, NextApiResponse } from 'next'
|
||||||
|
import { getSession } from 'next-auth/react'
|
||||||
|
import { methodNotAllowed } from '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 user = session.user as User
|
||||||
|
const id = req.query.id.toString()
|
||||||
|
if (user.id !== id) return res.status(401).send({ message: 'Forbidden' })
|
||||||
|
if (req.method === 'GET') {
|
||||||
|
const customDomains = await prisma.customDomain.findMany({
|
||||||
|
where: { ownerId: user.id },
|
||||||
|
})
|
||||||
|
return res.send({ customDomains })
|
||||||
|
}
|
||||||
|
if (req.method === 'POST') {
|
||||||
|
const data = JSON.parse(req.body) as Omit<CustomDomain, 'ownerId'>
|
||||||
|
try {
|
||||||
|
await createDomainOnVercel(data.name)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof HTTPError && err.response.statusCode !== 409)
|
||||||
|
return res.status(err.response.statusCode).send(err.response.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
const customDomains = await prisma.customDomain.create({
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
ownerId: user.id,
|
||||||
|
} as Prisma.CustomDomainUncheckedCreateInput,
|
||||||
|
})
|
||||||
|
return res.send({ customDomains })
|
||||||
|
}
|
||||||
|
return methodNotAllowed(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
const createDomainOnVercel = (name: string) =>
|
||||||
|
got.post({
|
||||||
|
url: `https://api.vercel.com/v8/projects/${process.env.VERCEL_VIEWER_PROJECT_NAME}/domains?teamId=${process.env.VERCEL_TEAM_ID}`,
|
||||||
|
headers: { Authorization: `Bearer ${process.env.VERCEL_TOKEN}` },
|
||||||
|
json: { name },
|
||||||
|
})
|
||||||
|
|
||||||
|
export default withSentry(handler)
|
35
apps/builder/pages/api/users/[id]/customDomains/[domain].ts
Normal file
35
apps/builder/pages/api/users/[id]/customDomains/[domain].ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { withSentry } from '@sentry/nextjs'
|
||||||
|
import { User } from 'db'
|
||||||
|
import prisma from 'libs/prisma'
|
||||||
|
import { NextApiRequest, NextApiResponse } from 'next'
|
||||||
|
import { getSession } from 'next-auth/react'
|
||||||
|
import { methodNotAllowed } from 'utils'
|
||||||
|
import { got } from 'got'
|
||||||
|
|
||||||
|
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
|
const session = await getSession({ req })
|
||||||
|
|
||||||
|
if (!session?.user)
|
||||||
|
return res.status(401).json({ message: 'Not authenticated' })
|
||||||
|
|
||||||
|
const user = session.user as User
|
||||||
|
const id = req.query.id.toString()
|
||||||
|
if (user.id !== id) return res.status(401).send({ message: 'Forbidden' })
|
||||||
|
if (req.method === 'DELETE') {
|
||||||
|
const domain = req.query.domain.toString()
|
||||||
|
await deleteDomainOnVercel(domain)
|
||||||
|
const customDomains = await prisma.customDomain.delete({
|
||||||
|
where: { name: domain },
|
||||||
|
})
|
||||||
|
return res.send({ customDomains })
|
||||||
|
}
|
||||||
|
return methodNotAllowed(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteDomainOnVercel = (name: string) =>
|
||||||
|
got.delete({
|
||||||
|
url: `https://api.vercel.com/v8/projects/${process.env.VERCEL_VIEWER_PROJECT_NAME}/domains/${name}?teamId=${process.env.VERCEL_TEAM_ID}`,
|
||||||
|
headers: { Authorization: `Bearer ${process.env.VERCEL_TOKEN}` },
|
||||||
|
})
|
||||||
|
|
||||||
|
export default withSentry(handler)
|
@ -139,6 +139,7 @@ const parseTypebotToPublicTypebot = (
|
|||||||
publicId: typebot.publicId,
|
publicId: typebot.publicId,
|
||||||
variables: typebot.variables,
|
variables: typebot.variables,
|
||||||
edges: typebot.edges,
|
edges: typebot.edges,
|
||||||
|
customDomain: null,
|
||||||
})
|
})
|
||||||
|
|
||||||
const parseBlocksToPublicBlocks = (blocks: Block[]): PublicBlock[] =>
|
const parseBlocksToPublicBlocks = (blocks: Block[]): PublicBlock[] =>
|
||||||
@ -160,6 +161,7 @@ const parseTestTypebot = (partialTypebot: Partial<Typebot>): Typebot => ({
|
|||||||
publicId: null,
|
publicId: null,
|
||||||
publishedTypebotId: null,
|
publishedTypebotId: null,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
|
customDomain: null,
|
||||||
variables: [{ id: 'var1', name: 'var1' }],
|
variables: [{ id: 'var1', name: 'var1' }],
|
||||||
...partialTypebot,
|
...partialTypebot,
|
||||||
edges: [
|
edges: [
|
||||||
|
63
apps/builder/playwright/tests/customDomains.spec.ts
Normal file
63
apps/builder/playwright/tests/customDomains.spec.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import test, { expect } from '@playwright/test'
|
||||||
|
import { InputStepType, defaultTextInputOptions } from 'models'
|
||||||
|
import { createTypebots, parseDefaultBlockWithStep } from '../services/database'
|
||||||
|
import { generate } from 'short-uuid'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
const typebotId = generate()
|
||||||
|
test.describe('Dashboard page', () => {
|
||||||
|
test('folders navigation should work', async ({ page }) => {
|
||||||
|
await createTypebots([
|
||||||
|
{
|
||||||
|
id: typebotId,
|
||||||
|
...parseDefaultBlockWithStep({
|
||||||
|
type: InputStepType.TEXT,
|
||||||
|
options: defaultTextInputOptions,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
await page.goto(`/typebots/${typebotId}/share`)
|
||||||
|
await page.click('text=Add my domain')
|
||||||
|
await page.click('text=Connect new')
|
||||||
|
await page.fill('input[placeholder="bot.my-domain.com"]', 'test')
|
||||||
|
await expect(page.locator('text=Save')).toBeDisabled()
|
||||||
|
await page.fill('input[placeholder="bot.my-domain.com"]', 'yolozeeer.com')
|
||||||
|
await expect(page.locator('text="A"')).toBeVisible()
|
||||||
|
await page.fill(
|
||||||
|
'input[placeholder="bot.my-domain.com"]',
|
||||||
|
'sub.yolozeeer.com'
|
||||||
|
)
|
||||||
|
await expect(page.locator('text="CNAME"')).toBeVisible()
|
||||||
|
await page.click('text=Save')
|
||||||
|
await expect(
|
||||||
|
page.locator('text="https://sub.yolozeeer.com/"')
|
||||||
|
).toBeVisible()
|
||||||
|
await page.click('text="Edit" >> nth=1')
|
||||||
|
await page.fill(
|
||||||
|
'text=https://sub.yolozeeer.com/Copy >> input',
|
||||||
|
'custom-path'
|
||||||
|
)
|
||||||
|
await page.press(
|
||||||
|
'text=https://sub.yolozeeer.com/custom-path >> input',
|
||||||
|
'Enter'
|
||||||
|
)
|
||||||
|
await expect(page.locator('text="custom-path"')).toBeVisible()
|
||||||
|
await page.click('[aria-label="Remove custom domain"]')
|
||||||
|
await expect(page.locator('text=sub.yolozeeer.com')).toBeHidden()
|
||||||
|
await page.click('button >> text=Add my domain')
|
||||||
|
await page.click('[aria-label="Remove domain"]')
|
||||||
|
await expect(page.locator('[aria-label="Remove domain"]')).toBeHidden()
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Free user', () => {
|
||||||
|
test.use({
|
||||||
|
storageState: path.join(__dirname, '../freeUser.json'),
|
||||||
|
})
|
||||||
|
test("create folder shouldn't be available", async ({ page }) => {
|
||||||
|
await page.goto(`/typebots/${typebotId}/share`)
|
||||||
|
await page.click('text=Add my domain')
|
||||||
|
await expect(page.locator('text=Upgrade now')).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
47
apps/builder/services/customDomains.ts
Normal file
47
apps/builder/services/customDomains.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { CustomDomain } from 'db'
|
||||||
|
import { Credentials } from 'models'
|
||||||
|
import useSWR from 'swr'
|
||||||
|
import { sendRequest } from 'utils'
|
||||||
|
import { fetcher } from './utils'
|
||||||
|
|
||||||
|
export const useCustomDomains = ({
|
||||||
|
userId,
|
||||||
|
onError,
|
||||||
|
}: {
|
||||||
|
userId?: string
|
||||||
|
onError: (error: Error) => void
|
||||||
|
}) => {
|
||||||
|
const { data, error, mutate } = useSWR<
|
||||||
|
{ customDomains: CustomDomain[] },
|
||||||
|
Error
|
||||||
|
>(userId ? `/api/users/${userId}/customDomains` : null, fetcher)
|
||||||
|
if (error) onError(error)
|
||||||
|
return {
|
||||||
|
customDomains: data?.customDomains,
|
||||||
|
isLoading: !error && !data,
|
||||||
|
mutate,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createCustomDomain = async (
|
||||||
|
userId: string,
|
||||||
|
customDomain: Omit<CustomDomain, 'ownerId'>
|
||||||
|
) =>
|
||||||
|
sendRequest<{
|
||||||
|
credentials: Credentials
|
||||||
|
}>({
|
||||||
|
url: `/api/users/${userId}/customDomains`,
|
||||||
|
method: 'POST',
|
||||||
|
body: customDomain,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const deleteCustomDomain = async (
|
||||||
|
userId: string,
|
||||||
|
customDomain: string
|
||||||
|
) =>
|
||||||
|
sendRequest<{
|
||||||
|
credentials: Credentials
|
||||||
|
}>({
|
||||||
|
url: `/api/users/${userId}/customDomains/${customDomain}`,
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
@ -18,6 +18,7 @@ export const parseTypebotToPublicTypebot = (
|
|||||||
settings: typebot.settings,
|
settings: typebot.settings,
|
||||||
theme: typebot.theme,
|
theme: typebot.theme,
|
||||||
variables: typebot.variables,
|
variables: typebot.variables,
|
||||||
|
customDomain: typebot.customDomain,
|
||||||
})
|
})
|
||||||
|
|
||||||
export const parseBlocksToPublicBlocks = (blocks: Block[]): PublicBlock[] =>
|
export const parseBlocksToPublicBlocks = (blocks: Block[]): PublicBlock[] =>
|
||||||
|
@ -241,7 +241,12 @@ export const parseNewTypebot = ({
|
|||||||
name: string
|
name: string
|
||||||
}): Omit<
|
}): Omit<
|
||||||
Typebot,
|
Typebot,
|
||||||
'createdAt' | 'updatedAt' | 'id' | 'publishedTypebotId' | 'publicId'
|
| 'createdAt'
|
||||||
|
| 'updatedAt'
|
||||||
|
| 'id'
|
||||||
|
| 'publishedTypebotId'
|
||||||
|
| 'publicId'
|
||||||
|
| 'customDomain'
|
||||||
> => {
|
> => {
|
||||||
const startBlockId = shortId.generate()
|
const startBlockId = shortId.generate()
|
||||||
const startStepId = shortId.generate()
|
const startStepId = shortId.generate()
|
||||||
|
@ -12,7 +12,13 @@ export const getServerSideProps: GetServerSideProps = async (
|
|||||||
const pathname = context.resolvedUrl.split('?')[0]
|
const pathname = context.resolvedUrl.split('?')[0]
|
||||||
try {
|
try {
|
||||||
if (!context.req.headers.host) return { props: {} }
|
if (!context.req.headers.host) return { props: {} }
|
||||||
typebot = await getTypebotFromPublicId(context.query.publicId?.toString())
|
typebot = context.req.headers.host.includes(
|
||||||
|
(process.env.NEXT_PUBLIC_VIEWER_HOST ?? '').split('//')[1]
|
||||||
|
)
|
||||||
|
? await getTypebotFromPublicId(context.query.publicId?.toString())
|
||||||
|
: await getTypebotFromCustomDomain(
|
||||||
|
`${context.req.headers.host}${pathname}`
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
typebot,
|
typebot,
|
||||||
@ -31,9 +37,7 @@ export const getServerSideProps: GetServerSideProps = async (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getTypebotFromPublicId = async (
|
const getTypebotFromPublicId = async (publicId?: string) => {
|
||||||
publicId?: string
|
|
||||||
): Promise<PublicTypebot | null> => {
|
|
||||||
if (!publicId) return null
|
if (!publicId) return null
|
||||||
const typebot = await prisma.publicTypebot.findUnique({
|
const typebot = await prisma.publicTypebot.findUnique({
|
||||||
where: { publicId },
|
where: { publicId },
|
||||||
@ -41,6 +45,13 @@ const getTypebotFromPublicId = async (
|
|||||||
return (typebot as unknown as PublicTypebot) ?? null
|
return (typebot as unknown as PublicTypebot) ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getTypebotFromCustomDomain = async (customDomain: string) => {
|
||||||
|
const typebot = await prisma.publicTypebot.findUnique({
|
||||||
|
where: { customDomain },
|
||||||
|
})
|
||||||
|
return (typebot as unknown as PublicTypebot) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
const App = ({ typebot, ...props }: TypebotPageProps) =>
|
const App = ({ typebot, ...props }: TypebotPageProps) =>
|
||||||
typebot ? <TypebotPage typebot={typebot} {...props} /> : <NotFoundPage />
|
typebot ? <TypebotPage typebot={typebot} {...props} /> : <NotFoundPage />
|
||||||
|
|
@ -0,0 +1,30 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- A unique constraint covering the columns `[customDomain]` on the table `PublicTypebot` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
- A unique constraint covering the columns `[customDomain]` on the table `Typebot` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "PublicTypebot" ADD COLUMN "customDomain" TEXT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Typebot" ADD COLUMN "customDomain" TEXT;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "CustomDomain" (
|
||||||
|
"ownerId" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "CustomDomain_name_key" ON "CustomDomain"("name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "PublicTypebot_customDomain_key" ON "PublicTypebot"("customDomain");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Typebot_customDomain_key" ON "Typebot"("customDomain");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "CustomDomain" ADD CONSTRAINT "CustomDomain_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
@ -50,6 +50,13 @@ model User {
|
|||||||
plan Plan @default(FREE)
|
plan Plan @default(FREE)
|
||||||
stripeId String? @unique
|
stripeId String? @unique
|
||||||
credentials Credentials[]
|
credentials Credentials[]
|
||||||
|
customDomains CustomDomain[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model CustomDomain {
|
||||||
|
ownerId String
|
||||||
|
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||||||
|
name String @unique
|
||||||
}
|
}
|
||||||
|
|
||||||
model Credentials {
|
model Credentials {
|
||||||
@ -109,19 +116,21 @@ model Typebot {
|
|||||||
theme Json
|
theme Json
|
||||||
settings Json
|
settings Json
|
||||||
publicId String? @unique
|
publicId String? @unique
|
||||||
|
customDomain String? @unique
|
||||||
}
|
}
|
||||||
|
|
||||||
model PublicTypebot {
|
model PublicTypebot {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
typebotId String @unique
|
typebotId String @unique
|
||||||
typebot Typebot @relation(fields: [typebotId], references: [id], onDelete: Cascade)
|
typebot Typebot @relation(fields: [typebotId], references: [id], onDelete: Cascade)
|
||||||
name String
|
name String
|
||||||
blocks Json[]
|
blocks Json[]
|
||||||
variables Json[]
|
variables Json[]
|
||||||
edges Json[]
|
edges Json[]
|
||||||
theme Json
|
theme Json
|
||||||
settings Json
|
settings Json
|
||||||
publicId String? @unique
|
publicId String? @unique
|
||||||
|
customDomain String? @unique
|
||||||
}
|
}
|
||||||
|
|
||||||
model Result {
|
model Result {
|
||||||
|
Reference in New Issue
Block a user