2
0

feat: Add custom domains

This commit is contained in:
Baptiste Arnaud
2022-02-18 14:57:10 +01:00
parent 1c178e01a6
commit f3ecb948a1
18 changed files with 694 additions and 66 deletions

View File

@ -14,23 +14,25 @@ import { CopyButton } from 'components/shared/buttons/CopyButton'
import React from 'react'
type EditableUrlProps = {
publicId?: string
onPublicIdChange: (publicId: string) => void
hostname: string
pathname?: string
onPathnameChange: (pathname: string) => void
}
export const EditableUrl = ({
publicId,
onPublicIdChange,
hostname,
pathname,
onPathnameChange,
}: EditableUrlProps) => {
return (
<Editable
as={HStack}
spacing={3}
defaultValue={publicId}
onSubmit={onPublicIdChange}
defaultValue={pathname}
onSubmit={onPathnameChange}
>
<HStack spacing={1}>
<Text>{process.env.NEXT_PUBLIC_VIEWER_HOST}/</Text>
<Text>{hostname}/</Text>
<Tooltip label="Edit">
<EditablePreview
mx={1}
@ -48,10 +50,7 @@ export const EditableUrl = ({
<HStack>
<EditButton size="xs" />
<CopyButton
size="xs"
textToCopy={`${process.env.NEXT_PUBLIC_VIEWER_HOST}/${publicId}`}
/>
<CopyButton size="xs" textToCopy={`${hostname}/${pathname}`} />
</HStack>
</Editable>
)

View File

@ -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 { useUser } from 'contexts/UserContext'
import React from 'react'
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 { integrationsList } from './integrations/EmbedButton'
export const ShareContent = () => {
const { user } = useUser()
const { typebot, updateOnBothTypebots } = useTypebot()
const toast = useToast({
position: 'top-right',
status: 'error',
})
const handlePublicIdChange = (publicId: string) => {
if (publicId === typebot?.publicId) return
if (publicId.length < 4)
return toast({ description: 'ID must be longer than 4 characters' })
updateOnBothTypebots({ publicId })
}
@ -19,19 +41,58 @@ export const ShareContent = () => {
: ''
const isPublished = isDefined(typebot?.publishedTypebotId)
const handleCustomDomainChange = (customDomain: string | null) =>
updateOnBothTypebots({ customDomain })
return (
<Flex h="full" w="full" justifyContent="center" align="flex-start">
<Stack maxW="1000px" w="full" pt="10" spacing={10}>
<Stack spacing={4}>
<Stack spacing={4} align="flex-start">
<Heading fontSize="2xl" as="h1">
Your typebot link
</Heading>
{typebot && (
<EditableUrl
publicId={publicId}
onPublicIdChange={handlePublicIdChange}
hostname={
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 spacing={4}>

View 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,
}
}

View File

@ -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>
)
}