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

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