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

@ -49,3 +49,8 @@ NEXT_PUBLIC_VIEWER_HOST=http://localhost:3001
# (Optional) Error tracking with Sentry
NEXT_PUBLIC_SENTRY_DSN=
# Vercel
VERCEL_TOKEN=
VERCEL_VIEWER_PROJECT_NAME=
VERCEL_TEAM_ID=

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

View File

@ -9,7 +9,7 @@ export const UpgradeButton = ({ type, ...props }: Props) => {
const { isOpen, onOpen, onClose } = useDisclosure()
return (
<Button colorScheme="blue" {...props} onClick={onOpen}>
Upgrade
{props.children ?? 'Upgrade'}
<UpgradeModal isOpen={isOpen} onClose={onClose} type={type} />
</Button>
)

View File

@ -60,6 +60,7 @@ const typebotContext = createContext<
updateOnBothTypebots: (updates: {
publicId?: string
name?: string
customDomain?: string | null
}) => void
publishTypebot: () => void
} & BlocksActions &
@ -144,8 +145,6 @@ export const TypebotContext = ({
debounceTimeout: autoSaveTimeout,
})
const [localPublishedTypebot, setLocalPublishedTypebot] =
useState<PublicTypebot>()
const [isSavingLoading, setIsSavingLoading] = useState(false)
const [isPublishing, setIsPublishing] = useState(false)
@ -177,7 +176,6 @@ export const TypebotContext = ({
return
}
setLocalTypebot({ ...typebot })
if (publishedTypebot) setLocalPublishedTypebot({ ...publishedTypebot })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isLoading])
@ -206,25 +204,15 @@ export const TypebotContext = ({
const updateLocalTypebot = (updates: UpdateTypebotPayload) =>
localTypebot && setLocalTypebot({ ...localTypebot, ...updates })
const updateLocalPublishedTypebot = (updates: UpdateTypebotPayload) =>
publishedTypebot &&
setLocalPublishedTypebot({
...localPublishedTypebot,
...(updates as PublicTypebot),
})
const publishTypebot = async () => {
if (!localTypebot) return
const publishedTypebotId = generate()
const newLocalTypebot = { ...localTypebot }
if (
localPublishedTypebot &&
isNotDefined(localTypebot.publishedTypebotId)
) {
updateLocalTypebot({ publishedTypebotId: localPublishedTypebot.id })
if (publishedTypebot && isNotDefined(localTypebot.publishedTypebotId)) {
updateLocalTypebot({ publishedTypebotId: publishedTypebot.id })
await saveTypebot()
}
if (!localPublishedTypebot) {
if (!publishedTypebot) {
const newPublicId = parseDefaultPublicId(
localTypebot.name,
localTypebot.id
@ -233,10 +221,10 @@ export const TypebotContext = ({
newLocalTypebot.publicId = newPublicId
await saveTypebot()
}
if (localPublishedTypebot) {
if (publishedTypebot) {
await savePublishedTypebot({
...parseTypebotToPublicTypebot(newLocalTypebot),
id: localPublishedTypebot.id,
id: publishedTypebot.id,
})
} else {
setIsPublishing(true)
@ -244,24 +232,23 @@ export const TypebotContext = ({
...parseTypebotToPublicTypebot(newLocalTypebot),
id: publishedTypebotId,
})
setLocalPublishedTypebot(data)
setIsPublishing(false)
if (error) return toast({ title: error.name, description: error.message })
mutate({ typebot: localTypebot })
mutate({ typebot: localTypebot, publishedTypebot: data })
}
}
const updateOnBothTypebots = async (updates: {
publicId?: string
name?: string
customDomain?: string | null
}) => {
updateLocalTypebot(updates)
await saveTypebot()
if (!localPublishedTypebot) return
updateLocalPublishedTypebot(updates)
if (!publishedTypebot) return
await savePublishedTypebot({
...localPublishedTypebot,
...(updates as PublicTypebot),
...publishedTypebot,
...updates,
})
}
@ -269,7 +256,7 @@ export const TypebotContext = ({
<typebotContext.Provider
value={{
typebot: localTypebot,
publishedTypebot: localPublishedTypebot,
publishedTypebot,
hasUnsavedChanges,
isSavingLoading,
save: saveTypebot,

View File

@ -37,11 +37,16 @@ export const UserContext = ({ children }: { children: ReactNode }) => {
const router = useRouter()
const { data: session, status } = useSession()
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,
onError: (error) =>
toast({ title: error.name, description: error.message }),
})
const [isSaving, setIsSaving] = useState(false)
const isOAuthProvider = useMemo(
() => (session?.providerType as boolean | undefined) ?? false,
@ -53,11 +58,6 @@ export const UserContext = ({ children }: { children: ReactNode }) => {
[session?.user, user]
)
const toast = useToast({
position: 'top-right',
status: 'error',
})
useEffect(() => {
if (isDefined(user) || isNotDefined(session)) return
setUser(session.user as User)
@ -92,14 +92,14 @@ export const UserContext = ({ children }: { children: ReactNode }) => {
<userContext.Provider
value={{
user,
updateUser,
saveUser,
isSaving,
isLoading: status === 'loading',
hasUnsavedChanges,
isOAuthProvider,
credentials: credentials ?? [],
mutateCredentials: mutate,
mutateCredentials,
updateUser,
saveUser,
}}
>
{children}

View 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)

View 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)

View File

@ -139,6 +139,7 @@ const parseTypebotToPublicTypebot = (
publicId: typebot.publicId,
variables: typebot.variables,
edges: typebot.edges,
customDomain: null,
})
const parseBlocksToPublicBlocks = (blocks: Block[]): PublicBlock[] =>
@ -160,6 +161,7 @@ const parseTestTypebot = (partialTypebot: Partial<Typebot>): Typebot => ({
publicId: null,
publishedTypebotId: null,
updatedAt: new Date(),
customDomain: null,
variables: [{ id: 'var1', name: 'var1' }],
...partialTypebot,
edges: [

View 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()
})
})
})

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

View File

@ -18,6 +18,7 @@ export const parseTypebotToPublicTypebot = (
settings: typebot.settings,
theme: typebot.theme,
variables: typebot.variables,
customDomain: typebot.customDomain,
})
export const parseBlocksToPublicBlocks = (blocks: Block[]): PublicBlock[] =>

View File

@ -241,7 +241,12 @@ export const parseNewTypebot = ({
name: string
}): Omit<
Typebot,
'createdAt' | 'updatedAt' | 'id' | 'publishedTypebotId' | 'publicId'
| 'createdAt'
| 'updatedAt'
| 'id'
| 'publishedTypebotId'
| 'publicId'
| 'customDomain'
> => {
const startBlockId = shortId.generate()
const startStepId = shortId.generate()

View File

@ -12,7 +12,13 @@ export const getServerSideProps: GetServerSideProps = async (
const pathname = context.resolvedUrl.split('?')[0]
try {
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 {
props: {
typebot,
@ -31,9 +37,7 @@ export const getServerSideProps: GetServerSideProps = async (
}
}
const getTypebotFromPublicId = async (
publicId?: string
): Promise<PublicTypebot | null> => {
const getTypebotFromPublicId = async (publicId?: string) => {
if (!publicId) return null
const typebot = await prisma.publicTypebot.findUnique({
where: { publicId },
@ -41,6 +45,13 @@ const getTypebotFromPublicId = async (
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) =>
typebot ? <TypebotPage typebot={typebot} {...props} /> : <NotFoundPage />

View File

@ -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;

View File

@ -50,6 +50,13 @@ model User {
plan Plan @default(FREE)
stripeId String? @unique
credentials Credentials[]
customDomains CustomDomain[]
}
model CustomDomain {
ownerId String
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
name String @unique
}
model Credentials {
@ -109,19 +116,21 @@ model Typebot {
theme Json
settings Json
publicId String? @unique
customDomain String? @unique
}
model PublicTypebot {
id String @id @default(cuid())
typebotId String @unique
typebot Typebot @relation(fields: [typebotId], references: [id], onDelete: Cascade)
name String
blocks Json[]
variables Json[]
edges Json[]
theme Json
settings Json
publicId String? @unique
id String @id @default(cuid())
typebotId String @unique
typebot Typebot @relation(fields: [typebotId], references: [id], onDelete: Cascade)
name String
blocks Json[]
variables Json[]
edges Json[]
theme Json
settings Json
publicId String? @unique
customDomain String? @unique
}
model Result {