diff --git a/apps/builder/components/board/preview/PreviewDrawer.tsx b/apps/builder/components/board/preview/PreviewDrawer.tsx index e7cf54081f..45a4379888 100644 --- a/apps/builder/components/board/preview/PreviewDrawer.tsx +++ b/apps/builder/components/board/preview/PreviewDrawer.tsx @@ -14,7 +14,7 @@ import { useEditor } from 'contexts/EditorContext' import { useGraph } from 'contexts/GraphContext' import { useTypebot } from 'contexts/TypebotContext' import React, { useMemo, useState } from 'react' -import { parseTypebotToPublicTypebot } from 'services/typebots' +import { parseTypebotToPublicTypebot } from 'services/publicTypebot' export const PreviewDrawer = () => { const { typebot } = useTypebot() diff --git a/apps/builder/components/shared/buttons/PublishButton.tsx b/apps/builder/components/shared/buttons/PublishButton.tsx index 6106e59389..a3346c38be 100644 --- a/apps/builder/components/shared/buttons/PublishButton.tsx +++ b/apps/builder/components/shared/buttons/PublishButton.tsx @@ -1,9 +1,18 @@ import { Button } from '@chakra-ui/react' +import { useTypebot } from 'contexts/TypebotContext' export const PublishButton = () => { + const { isPublishing, isPublished, publishTypebot } = useTypebot() + return ( - ) } diff --git a/apps/builder/components/theme/ThemeContent.tsx b/apps/builder/components/theme/ThemeContent.tsx index 9db8596dd3..50613fe879 100644 --- a/apps/builder/components/theme/ThemeContent.tsx +++ b/apps/builder/components/theme/ThemeContent.tsx @@ -2,7 +2,7 @@ import { Flex } from '@chakra-ui/react' import { TypebotViewer } from 'bot-engine' import { useTypebot } from 'contexts/TypebotContext' import React, { useMemo } from 'react' -import { parseTypebotToPublicTypebot } from 'services/typebots' +import { parseTypebotToPublicTypebot } from 'services/publicTypebot' import { SideMenu } from './SideMenu' export const ThemeContent = () => { diff --git a/apps/builder/contexts/TypebotContext.tsx b/apps/builder/contexts/TypebotContext.tsx index 41d8661a21..dd5b1818ec 100644 --- a/apps/builder/contexts/TypebotContext.tsx +++ b/apps/builder/contexts/TypebotContext.tsx @@ -1,6 +1,7 @@ import { useToast } from '@chakra-ui/react' import { Block, + PublicTypebot, Settings, Step, StepType, @@ -8,16 +9,25 @@ import { Theme, Typebot, } from 'bot-engine' +import { deepEqual } from 'fast-equals' import { useRouter } from 'next/router' import { createContext, ReactNode, useContext, useEffect, + useMemo, useState, } from 'react' import { + createPublishedTypebot, + parseTypebotToPublicTypebot, + updatePublishedTypebot, +} from 'services/publicTypebot' +import { + checkIfPublished, checkIfTypebotsAreEqual, + parseDefaultPublicId, parseNewBlock, parseNewStep, updateTypebot, @@ -25,6 +35,8 @@ import { import { fetcher, insertItemInList, + isDefined, + omit, preventUserFromRefreshing, } from 'services/utils' import useSWR from 'swr' @@ -32,6 +44,9 @@ import { NewBlockPayload, Coordinates } from './GraphContext' const typebotContext = createContext<{ typebot?: Typebot + publishedTypebot?: PublicTypebot + isPublished: boolean + isPublishing: boolean hasUnsavedChanges: boolean isSavingLoading: boolean save: () => void @@ -57,6 +72,7 @@ const typebotContext = createContext<{ updateTheme: (theme: Theme) => void updateSettings: (settings: Settings) => void updatePublicId: (publicId: string) => void + publishTypebot: () => void // eslint-disable-next-line @typescript-eslint/ban-ts-comment //@ts-ignore }>({}) @@ -74,7 +90,7 @@ export const TypebotContext = ({ status: 'error', }) const [undoStack, setUndoStack] = useState([]) - const { typebot, isLoading, mutate } = useFetchedTypebot({ + const { typebot, publishedTypebot, isLoading, mutate } = useFetchedTypebot({ typebotId, onError: (error) => toast({ @@ -83,18 +99,33 @@ export const TypebotContext = ({ }), }) const [localTypebot, setLocalTypebot] = useState() - const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false) + const [localPublishedTypebot, setLocalPublishedTypebot] = + useState() const [isSavingLoading, setIsSavingLoading] = useState(false) + const [isPublishing, setIsPublishing] = useState(false) + + const hasUnsavedChanges = useMemo( + () => + isDefined(typebot) && + isDefined(localTypebot) && + !deepEqual(localTypebot, typebot), + [typebot, localTypebot] + ) + const isPublished = useMemo( + () => + isDefined(typebot) && + isDefined(publishedTypebot) && + checkIfPublished(typebot, publishedTypebot), + [typebot, publishedTypebot] + ) useEffect(() => { if (!localTypebot || !typebot) return if (!checkIfTypebotsAreEqual(localTypebot, typebot)) { - setHasUnsavedChanges(true) pushNewTypebotInUndoStack(localTypebot) window.removeEventListener('beforeunload', preventUserFromRefreshing) window.addEventListener('beforeunload', preventUserFromRefreshing) } else { - setHasUnsavedChanges(false) window.removeEventListener('beforeunload', preventUserFromRefreshing) } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -108,6 +139,7 @@ export const TypebotContext = ({ return } setLocalTypebot({ ...typebot }) + if (publishedTypebot) setLocalPublishedTypebot({ ...publishedTypebot }) // eslint-disable-next-line react-hooks/exhaustive-deps }, [isLoading]) @@ -128,7 +160,6 @@ export const TypebotContext = ({ setIsSavingLoading(false) if (error) return toast({ title: error.name, description: error.message }) mutate({ typebot: localTypebot }) - setHasUnsavedChanges(false) window.removeEventListener('beforeunload', preventUserFromRefreshing) } @@ -290,10 +321,34 @@ export const TypebotContext = ({ setLocalTypebot({ ...localTypebot, publicId }) } + const publishTypebot = async () => { + if (!localTypebot) return + if (!localPublishedTypebot) + updatePublicId(parseDefaultPublicId(localTypebot.name, localTypebot.id)) + if (hasUnsavedChanges) await saveTypebot() + setIsPublishing(true) + if (localPublishedTypebot) { + const { error } = await updatePublishedTypebot( + localPublishedTypebot.id, + omit(parseTypebotToPublicTypebot(localTypebot), 'id') + ) + setIsPublishing(false) + if (error) return toast({ title: error.name, description: error.message }) + } else { + const { error } = await createPublishedTypebot( + omit(parseTypebotToPublicTypebot(localTypebot), 'id') + ) + setIsPublishing(false) + if (error) return toast({ title: error.name, description: error.message }) + } + mutate({ typebot: localTypebot }) + } + return ( {children} @@ -324,13 +382,14 @@ export const useFetchedTypebot = ({ typebotId?: string onError: (error: Error) => void }) => { - const { data, error, mutate } = useSWR<{ typebot: Typebot }, Error>( - typebotId ? `/api/typebots/${typebotId}` : null, - fetcher - ) + const { data, error, mutate } = useSWR< + { typebot: Typebot; publishedTypebot?: PublicTypebot }, + Error + >(typebotId ? `/api/typebots/${typebotId}` : null, fetcher) if (error) onError(error) return { typebot: data?.typebot, + publishedTypebot: data?.publishedTypebot, isLoading: !error && !data, mutate, } diff --git a/apps/builder/pages/api/publicTypebots.ts b/apps/builder/pages/api/publicTypebots.ts new file mode 100644 index 0000000000..9296999623 --- /dev/null +++ b/apps/builder/pages/api/publicTypebots.ts @@ -0,0 +1,30 @@ +import prisma from 'libs/prisma' +import { NextApiRequest, NextApiResponse } from 'next' +import { getSession } from 'next-auth/react' +import { methodNotAllowed } from 'services/api/utils' + +const handler = async (req: NextApiRequest, res: NextApiResponse) => { + const session = await getSession({ req }) + + if (!session?.user) + return res.status(401).json({ message: 'Not authenticated' }) + + try { + if (req.method === 'POST') { + const data = JSON.parse(req.body) + const typebot = await prisma.publicTypebot.create({ + data: { ...data }, + }) + return res.send(typebot) + } + return methodNotAllowed(res) + } catch (err) { + console.error(err) + if (err instanceof Error) { + return res.status(500).send({ title: err.name, message: err.message }) + } + return res.status(500).send({ message: 'An error occured', error: err }) + } +} + +export default handler diff --git a/apps/builder/pages/api/publicTypebots/[id].ts b/apps/builder/pages/api/publicTypebots/[id].ts new file mode 100644 index 0000000000..8788282bb4 --- /dev/null +++ b/apps/builder/pages/api/publicTypebots/[id].ts @@ -0,0 +1,24 @@ +import prisma from 'libs/prisma' +import { NextApiRequest, NextApiResponse } from 'next' +import { getSession } from 'next-auth/react' +import { methodNotAllowed } from 'services/api/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 id = req.query.id.toString() + if (req.method === 'PUT') { + const data = JSON.parse(req.body) + const typebots = await prisma.publicTypebot.update({ + where: { id }, + data, + }) + return res.send({ typebots }) + } + return methodNotAllowed(res) +} + +export default handler diff --git a/apps/builder/pages/api/typebots/[id].ts b/apps/builder/pages/api/typebots/[id].ts index e53689cf06..95cc30aadb 100644 --- a/apps/builder/pages/api/typebots/[id].ts +++ b/apps/builder/pages/api/typebots/[id].ts @@ -13,8 +13,13 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { if (req.method === 'GET') { const typebot = await prisma.typebot.findUnique({ where: { id }, + include: { + publishedTypebot: true, + }, }) - return res.send({ typebot }) + if (!typebot) return res.send({ typebot: null }) + const { publishedTypebot, ...restOfTypebot } = typebot + return res.send({ typebot: restOfTypebot, publishedTypebot }) } if (req.method === 'DELETE') { const typebots = await prisma.typebot.delete({ diff --git a/apps/builder/services/publicTypebot.ts b/apps/builder/services/publicTypebot.ts new file mode 100644 index 0000000000..631d1456a9 --- /dev/null +++ b/apps/builder/services/publicTypebot.ts @@ -0,0 +1,35 @@ +import { PublicTypebot, Typebot } from 'bot-engine' +import { sendRequest } from './utils' +import shortId from 'short-uuid' + +export const parseTypebotToPublicTypebot = ( + typebot: Typebot +): PublicTypebot => ({ + id: shortId.generate(), + blocks: typebot.blocks, + name: typebot.name, + startBlock: typebot.startBlock, + typebotId: typebot.id, + theme: typebot.theme, + settings: typebot.settings, + publicId: typebot.publicId, +}) + +export const createPublishedTypebot = async ( + typebot: Omit +) => + sendRequest({ + url: `/api/publicTypebots`, + method: 'POST', + body: typebot, + }) + +export const updatePublishedTypebot = async ( + id: string, + typebot: Omit +) => + sendRequest({ + url: `/api/publicTypebots/${id}`, + method: 'PUT', + body: typebot, + }) diff --git a/apps/builder/services/typebots.ts b/apps/builder/services/typebots.ts index a2a14379f8..cd0013f213 100644 --- a/apps/builder/services/typebots.ts +++ b/apps/builder/services/typebots.ts @@ -3,8 +3,8 @@ import { StepType, Block, TextStep, - PublicTypebot, TextInputStep, + PublicTypebot, } from 'bot-engine' import shortId from 'short-uuid' import { Typebot } from 'bot-engine' @@ -154,18 +154,16 @@ export const checkIfTypebotsAreEqual = ( } ) -export const parseTypebotToPublicTypebot = ( - typebot: Typebot -): PublicTypebot => ({ - id: shortId.generate(), - blocks: typebot.blocks, - name: typebot.name, - startBlock: typebot.startBlock, - typebotId: typebot.id, - theme: typebot.theme, - settings: typebot.settings, - publicId: typebot.publicId, -}) +export const checkIfPublished = ( + typebot: Typebot, + publicTypebot: PublicTypebot +) => + deepEqual(typebot.blocks, publicTypebot.blocks) && + deepEqual(typebot.startBlock, publicTypebot.startBlock) && + typebot.name === publicTypebot.name && + typebot.publicId === publicTypebot.publicId && + deepEqual(typebot.settings, publicTypebot.settings) && + deepEqual(typebot.theme, publicTypebot.theme) export const parseDefaultPublicId = (name: string, id: string) => toKebabCase(`${name}-${id?.slice(0, 5)}`) diff --git a/apps/builder/services/utils.ts b/apps/builder/services/utils.ts index b9f1fe8dc8..11d436fc9c 100644 --- a/apps/builder/services/utils.ts +++ b/apps/builder/services/utils.ts @@ -71,3 +71,23 @@ export const toKebabCase = (value: string) => { if (!matched) return '' return matched.map((x) => x.toLowerCase()).join('-') } + +interface Omit { + // eslint-disable-next-line @typescript-eslint/ban-types + (obj: T, ...keys: K): { + [K2 in Exclude]: T[K2] + } +} + +export const omit: Omit = (obj, ...keys) => { + const ret = {} as { + [K in keyof typeof obj]: typeof obj[K] + } + let key: keyof typeof obj + for (key in obj) { + if (!keys.includes(key)) { + ret[key] = obj[key] + } + } + return ret +} diff --git a/apps/viewer/assets/styles.css b/apps/viewer/assets/styles.css new file mode 100644 index 0000000000..293d3b1f13 --- /dev/null +++ b/apps/viewer/assets/styles.css @@ -0,0 +1,3 @@ +body { + margin: 0; +} diff --git a/apps/viewer/components/Seo.tsx b/apps/viewer/components/Seo.tsx new file mode 100644 index 0000000000..459382d381 --- /dev/null +++ b/apps/viewer/components/Seo.tsx @@ -0,0 +1,62 @@ +import Head from 'next/head' +import React from 'react' + +type SEOProps = any + +export const SEO = ({ + iconUrl, + thumbnailUrl, + title, + description, + url, + chatbotName, +}: SEOProps) => ( + + {title ?? chatbotName} + + + + + + + + + + + + + + + + + +) diff --git a/apps/viewer/layouts/ErrorPage.tsx b/apps/viewer/layouts/ErrorPage.tsx new file mode 100644 index 0000000000..f86270b7aa --- /dev/null +++ b/apps/viewer/layouts/ErrorPage.tsx @@ -0,0 +1,29 @@ +import React from 'react' + +export const ErrorPage = ({ error }: { error: 'offline' | '500' | 'IE' }) => { + let errorLabel = + 'An error occured. Please try to refresh or contact the owner of this bot.' + if (error === 'offline') { + errorLabel = + 'Looks like your device is offline. Please, try to refresh the page.' + } + if (error === 'IE') { + errorLabel = "This bot isn't compatible with Internet Explorer." + } + return ( +
+ {error === '500' && ( +

500

+ )} +

{errorLabel}

+
+ ) +} diff --git a/apps/viewer/layouts/NotFoundPage.tsx b/apps/viewer/layouts/NotFoundPage.tsx new file mode 100644 index 0000000000..5a3ed1b40a --- /dev/null +++ b/apps/viewer/layouts/NotFoundPage.tsx @@ -0,0 +1,16 @@ +import React from 'react' + +export const NotFoundPage = () => ( +
+

404

+

The bot you're looking for doesn't exist

+
+) diff --git a/apps/viewer/layouts/TypebotPage.tsx b/apps/viewer/layouts/TypebotPage.tsx new file mode 100644 index 0000000000..c66c431eee --- /dev/null +++ b/apps/viewer/layouts/TypebotPage.tsx @@ -0,0 +1,26 @@ +import { PublicTypebot, TypebotViewer } from 'bot-engine' +import React from 'react' +import { SEO } from '../components/Seo' +import { ErrorPage } from './ErrorPage' +import { NotFoundPage } from './NotFoundPage' + +export type TypebotPageProps = { + typebot?: PublicTypebot + url: string + isIE: boolean +} + +export const TypebotPage = ({ typebot, isIE, url }: TypebotPageProps) => { + if (!typebot) { + return + } + if (isIE) { + return + } + return ( +
+ + +
+ ) +} diff --git a/apps/viewer/libs/prisma.ts b/apps/viewer/libs/prisma.ts new file mode 100644 index 0000000000..f4e31f5b4e --- /dev/null +++ b/apps/viewer/libs/prisma.ts @@ -0,0 +1,15 @@ +import { PrismaClient } from 'db' + +declare const global: { prisma: PrismaClient } +let prisma: PrismaClient + +if (process.env.NODE_ENV === 'production') { + prisma = new PrismaClient() +} else { + if (!global.prisma) { + global.prisma = new PrismaClient() + } + prisma = global.prisma +} + +export default prisma diff --git a/apps/viewer/package.json b/apps/viewer/package.json index dfea953ab9..8ca8320ecc 100644 --- a/apps/viewer/package.json +++ b/apps/viewer/package.json @@ -11,20 +11,20 @@ "dependencies": { "bot-engine": "*", "db": "*", - "next": "^12.0.4", + "next": "^12.0.7", "react": "^17.0.2", "react-dom": "^17.0.2" }, "devDependencies": { - "@types/node": "^16.11.9", - "@types/react": "^17.0.35", - "@typescript-eslint/eslint-plugin": "^5.4.0", + "@types/node": "^17.0.4", + "@types/react": "^17.0.38", + "@typescript-eslint/eslint-plugin": "^5.8.0", "eslint": "<8.0.0", - "eslint-config-next": "12.0.4", + "eslint-config-next": "12.0.7", "eslint-config-prettier": "^8.3.0", "eslint-plugin-cypress": "^2.12.1", "eslint-plugin-prettier": "^4.0.0", - "prettier": "^2.4.1", + "prettier": "^2.5.1", "typescript": "^4.5.4" } } diff --git a/apps/viewer/pages/404.tsx b/apps/viewer/pages/404.tsx new file mode 100644 index 0000000000..2e8541be24 --- /dev/null +++ b/apps/viewer/pages/404.tsx @@ -0,0 +1,5 @@ +import React from 'react' +import { NotFoundPage } from '../layouts/NotFoundPage' + +const NotFoundErrorPage = () => +export default NotFoundErrorPage diff --git a/apps/viewer/pages/[publicId].tsx b/apps/viewer/pages/[publicId].tsx new file mode 100644 index 0000000000..3d4f943e0e --- /dev/null +++ b/apps/viewer/pages/[publicId].tsx @@ -0,0 +1,44 @@ +import { PublicTypebot } from 'bot-engine' +import { GetServerSideProps, GetServerSidePropsContext } from 'next' +import { TypebotPage, TypebotPageProps } from '../layouts/TypebotPage' +import prisma from '../libs/prisma' + +export const getServerSideProps: GetServerSideProps = async ( + context: GetServerSidePropsContext +) => { + let typebot: PublicTypebot | undefined + const isIE = /MSIE|Trident/.test(context.req.headers['user-agent'] ?? '') + const pathname = context.resolvedUrl.split('?')[0] + try { + if (!context.req.headers.host) return { props: {} } + typebot = await getTypebotFromPublicId(context.query.publicId.toString()) + if (!typebot) return { props: {} } + return { + props: { + typebot, + isIE, + url: `https://${context.req.headers.host}${pathname}`, + }, + } + } catch (err) { + console.error(err) + } + return { + props: { + isIE, + url: `https://${context.req.headers.host}${pathname}`, + }, + } +} + +const getTypebotFromPublicId = async ( + publicId: string +): Promise => { + const typebot = await prisma.publicTypebot.findUnique({ + where: { publicId }, + }) + return (typebot as unknown as PublicTypebot | undefined) ?? undefined +} + +const App = (props: TypebotPageProps) => +export default App diff --git a/apps/viewer/pages/_app.tsx b/apps/viewer/pages/_app.tsx new file mode 100644 index 0000000000..683c45b9ac --- /dev/null +++ b/apps/viewer/pages/_app.tsx @@ -0,0 +1,15 @@ +import React from 'react' +import '../assets/styles.css' + +type Props = { + Component: React.ComponentType + pageProps: { + [key: string]: unknown + } +} + +export default function MyApp({ Component, pageProps }: Props): JSX.Element { + const { ...componentProps } = pageProps + + return +} diff --git a/apps/viewer/pages/index.tsx b/apps/viewer/pages/index.tsx index c644ed0cc3..61e5bd4192 100644 --- a/apps/viewer/pages/index.tsx +++ b/apps/viewer/pages/index.tsx @@ -1,7 +1,46 @@ -import React from 'react' +import { PublicTypebot } from 'bot-engine' +import { GetServerSideProps, GetServerSidePropsContext } from 'next' +import { TypebotPage, TypebotPageProps } from '../layouts/TypebotPage' +import prisma from '../libs/prisma' -const HomePage = () => { - return
Welcome to "Viewer"!
+export const getServerSideProps: GetServerSideProps = async ( + context: GetServerSidePropsContext +) => { + let typebot: PublicTypebot | undefined + const isIE = /MSIE|Trident/.test(context.req.headers['user-agent'] ?? '') + const pathname = context.resolvedUrl.split('?')[0] + try { + if (!context.req.headers.host) return { props: {} } + typebot = await getTypebotFromUrl(context.req.headers.host) + if (!typebot) return { props: {} } + return { + props: { + typebot, + isIE, + url: `https://${context.req.headers.host}${pathname}`, + }, + } + } catch (err) { + console.error(err) + } + return { + props: { + isIE, + url: `https://${context.req.headers.host}${pathname}`, + }, + } } -export default HomePage +const getTypebotFromUrl = async ( + hostname: string +): Promise => { + const publicId = hostname.split('.').shift() + if (!publicId) return + const typebot = await prisma.publicTypebot.findUnique({ + where: { publicId }, + }) + return (typebot as unknown as PublicTypebot | undefined) ?? undefined +} + +const App = (props: TypebotPageProps) => +export default App diff --git a/package.json b/package.json index f111fed243..d8d4950289 100644 --- a/package.json +++ b/package.json @@ -11,13 +11,12 @@ "db:nuke": "docker-compose down --volumes --remove-orphans", "dev": "dotenv -e .env yarn docker:up && dotenv -e .env turbo run dev --parallel", "build": "dotenv -e .env turbo run build", - "build:builder": "yarn build --scope=builder --includeDependencies", "test": "dotenv -e .env turbo run dev test", "lint": "turbo run lint" }, "devDependencies": { - "dotenv-cli": "^4.1.0", - "turbo": "^1.0.19" + "dotenv-cli": "^4.1.1", + "turbo": "^1.0.23" }, "turbo": { "baseBranch": "origin/main", diff --git a/packages/bot-engine/src/components/TypebotViewer.tsx b/packages/bot-engine/src/components/TypebotViewer.tsx index d67e9ffba7..dabc067b9c 100644 --- a/packages/bot-engine/src/components/TypebotViewer.tsx +++ b/packages/bot-engine/src/components/TypebotViewer.tsx @@ -30,7 +30,7 @@ export const TypebotViewer = ({ {style}} - style={{ width: '100%' }} + style={{ width: '100%', height: '100%', border: 'none' }} >