diff --git a/apps/builder/.env.local.example b/apps/builder/.env.local.example
index 6971e98ef..c35465d98 100644
--- a/apps/builder/.env.local.example
+++ b/apps/builder/.env.local.example
@@ -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=
\ No newline at end of file
diff --git a/apps/builder/components/share/EditableUrl.tsx b/apps/builder/components/share/EditableUrl.tsx
index 24e040d92..b72a6837e 100644
--- a/apps/builder/components/share/EditableUrl.tsx
+++ b/apps/builder/components/share/EditableUrl.tsx
@@ -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 (
- {process.env.NEXT_PUBLIC_VIEWER_HOST}/
+ {hostname}/
-
+
)
diff --git a/apps/builder/components/share/ShareContent.tsx b/apps/builder/components/share/ShareContent.tsx
index 15da97c27..938aa047c 100644
--- a/apps/builder/components/share/ShareContent.tsx
+++ b/apps/builder/components/share/ShareContent.tsx
@@ -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 (
-
+
Your typebot link
{typebot && (
)}
+ {typebot?.customDomain && (
+
+
+ handleCustomDomainChange(
+ typebot.customDomain?.split('/')[0] + pathname
+ )
+ }
+ />
+ }
+ aria-label="Remove custom domain"
+ size="xs"
+ onClick={() => handleCustomDomainChange(null)}
+ />
+
+ )}
+ {isFreePlan(user) ? (
+
+ Add my domain{' '}
+ Pro
+
+ ) : (
+ <>
+ {isNotDefined(typebot?.customDomain) && (
+
+ )}
+ >
+ )}
diff --git a/apps/builder/components/share/customDomain/CustomDomainModal.tsx b/apps/builder/components/share/customDomain/CustomDomainModal.tsx
new file mode 100644
index 000000000..b794bab6e
--- /dev/null
+++ b/apps/builder/components/share/customDomain/CustomDomainModal.tsx
@@ -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(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 (
+
+
+
+
+ Add a custom domain
+
+
+
+
+ setInputValue(e.target.value)}
+ placeholder="bot.my-domain.com"
+ />
+ {hostname.domain !== '' && (
+ <>
+
+ Add the following record in your DNS provider to continue:
+
+ {hostname.subdomain ? (
+
+
+ Type
+ CNAME
+
+
+ Name
+ {hostname.subdomain}
+
+
+ Value
+ viewer.typebot.io
+
+
+ ) : (
+
+
+ Type
+ A
+
+
+ Name
+ *
+
+
+ Value
+ 76.76.21.21
+
+
+ )}
+
+ Depending on your provider, it might take some time for the
+ changes to apply
+
+ >
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+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,
+ }
+}
diff --git a/apps/builder/components/share/customDomain/CustomDomainsDropdown.tsx b/apps/builder/components/share/customDomain/CustomDomainsDropdown.tsx
new file mode 100644
index 000000000..da6c34159
--- /dev/null
+++ b/apps/builder/components/share/customDomain/CustomDomainsDropdown.tsx
@@ -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 & {
+ 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 (
+
+ )
+}
diff --git a/apps/builder/components/shared/buttons/UpgradeButton.tsx b/apps/builder/components/shared/buttons/UpgradeButton.tsx
index 0307b08f8..12f67f82d 100644
--- a/apps/builder/components/shared/buttons/UpgradeButton.tsx
+++ b/apps/builder/components/shared/buttons/UpgradeButton.tsx
@@ -9,7 +9,7 @@ export const UpgradeButton = ({ type, ...props }: Props) => {
const { isOpen, onOpen, onClose } = useDisclosure()
return (
)
diff --git a/apps/builder/contexts/TypebotContext/TypebotContext.tsx b/apps/builder/contexts/TypebotContext/TypebotContext.tsx
index e0267f50d..42de77542 100644
--- a/apps/builder/contexts/TypebotContext/TypebotContext.tsx
+++ b/apps/builder/contexts/TypebotContext/TypebotContext.tsx
@@ -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()
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 = ({
{
const router = useRouter()
const { data: session, status } = useSession()
const [user, setUser] = useState()
- 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 }) => {
{children}
diff --git a/apps/builder/pages/api/users/[id]/customDomains.ts b/apps/builder/pages/api/users/[id]/customDomains.ts
new file mode 100644
index 000000000..3c5704dc3
--- /dev/null
+++ b/apps/builder/pages/api/users/[id]/customDomains.ts
@@ -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
+ 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)
diff --git a/apps/builder/pages/api/users/[id]/customDomains/[domain].ts b/apps/builder/pages/api/users/[id]/customDomains/[domain].ts
new file mode 100644
index 000000000..93d962138
--- /dev/null
+++ b/apps/builder/pages/api/users/[id]/customDomains/[domain].ts
@@ -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)
diff --git a/apps/builder/playwright/services/database.ts b/apps/builder/playwright/services/database.ts
index 53ff87b34..404a48f05 100644
--- a/apps/builder/playwright/services/database.ts
+++ b/apps/builder/playwright/services/database.ts
@@ -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 => ({
publicId: null,
publishedTypebotId: null,
updatedAt: new Date(),
+ customDomain: null,
variables: [{ id: 'var1', name: 'var1' }],
...partialTypebot,
edges: [
diff --git a/apps/builder/playwright/tests/customDomains.spec.ts b/apps/builder/playwright/tests/customDomains.spec.ts
new file mode 100644
index 000000000..e083b5e39
--- /dev/null
+++ b/apps/builder/playwright/tests/customDomains.spec.ts
@@ -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()
+ })
+ })
+})
diff --git a/apps/builder/services/customDomains.ts b/apps/builder/services/customDomains.ts
new file mode 100644
index 000000000..dfb73a3cf
--- /dev/null
+++ b/apps/builder/services/customDomains.ts
@@ -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
+) =>
+ 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',
+ })
diff --git a/apps/builder/services/publicTypebot.tsx b/apps/builder/services/publicTypebot.tsx
index 0031c693d..028675ea6 100644
--- a/apps/builder/services/publicTypebot.tsx
+++ b/apps/builder/services/publicTypebot.tsx
@@ -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[] =>
diff --git a/apps/builder/services/typebots.ts b/apps/builder/services/typebots.ts
index f45db6149..bd2318dde 100644
--- a/apps/builder/services/typebots.ts
+++ b/apps/builder/services/typebots.ts
@@ -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()
diff --git a/apps/viewer/pages/[publicId].tsx b/apps/viewer/pages/[[...publicId]].tsx
similarity index 67%
rename from apps/viewer/pages/[publicId].tsx
rename to apps/viewer/pages/[[...publicId]].tsx
index 46a7892c6..9053ff219 100644
--- a/apps/viewer/pages/[publicId].tsx
+++ b/apps/viewer/pages/[[...publicId]].tsx
@@ -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 => {
+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 ? :
diff --git a/packages/db/prisma/migrations/20220218135618_add_custom_domains/migration.sql b/packages/db/prisma/migrations/20220218135618_add_custom_domains/migration.sql
new file mode 100644
index 000000000..85d2d89f1
--- /dev/null
+++ b/packages/db/prisma/migrations/20220218135618_add_custom_domains/migration.sql
@@ -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;
diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma
index e5f9cf4c6..0dd72264a 100644
--- a/packages/db/prisma/schema.prisma
+++ b/packages/db/prisma/schema.prisma
@@ -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 {