♻️ (builder) Change to features-centric folder structure
This commit is contained in:
committed by
Baptiste Arnaud
parent
3686465a85
commit
643571fe7d
@ -0,0 +1,106 @@
|
||||
import { VStack, Heading, Stack, Button, useDisclosure } from '@chakra-ui/react'
|
||||
import { ToolIcon, TemplateIcon, DownloadIcon } from '@/components/icons'
|
||||
import { Typebot } from 'models'
|
||||
import { useRouter } from 'next/router'
|
||||
import React, { useState } from 'react'
|
||||
import { ImportTypebotFromFileButton } from './ImportTypebotFromFileButton'
|
||||
import { TemplatesModal } from './TemplatesModal'
|
||||
import { useWorkspace } from '@/features/workspace'
|
||||
import { useUser } from '@/features/account'
|
||||
import { useToast } from '@/hooks/useToast'
|
||||
import { createTypebotQuery, importTypebotQuery } from '@/features/dashboard'
|
||||
|
||||
export const CreateNewTypebotButtons = () => {
|
||||
const { workspace } = useWorkspace()
|
||||
const { user } = useUser()
|
||||
const router = useRouter()
|
||||
const { isOpen, onOpen, onClose } = useDisclosure()
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const { showToast } = useToast()
|
||||
|
||||
const handleCreateSubmit = async (typebot?: Typebot) => {
|
||||
if (!user || !workspace) return
|
||||
setIsLoading(true)
|
||||
const folderId = router.query.folderId?.toString() ?? null
|
||||
const { error, data } = typebot
|
||||
? await importTypebotQuery(
|
||||
{
|
||||
...typebot,
|
||||
folderId,
|
||||
workspaceId: workspace.id,
|
||||
theme: {
|
||||
...typebot.theme,
|
||||
chat: {
|
||||
...typebot.theme.chat,
|
||||
hostAvatar: { isEnabled: true, url: user.image ?? undefined },
|
||||
},
|
||||
},
|
||||
},
|
||||
workspace.plan
|
||||
)
|
||||
: await createTypebotQuery({
|
||||
folderId,
|
||||
workspaceId: workspace.id,
|
||||
})
|
||||
if (error) showToast({ description: error.message })
|
||||
if (data)
|
||||
router.push({
|
||||
pathname: `/typebots/${data.id}/edit`,
|
||||
query:
|
||||
router.query.isFirstBot === 'true'
|
||||
? {
|
||||
isFirstBot: 'true',
|
||||
}
|
||||
: {},
|
||||
})
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack maxW="600px" w="full" flex="1" pt="20" spacing={10}>
|
||||
<Heading>Create a new typebot</Heading>
|
||||
<Stack w="full" spacing={6}>
|
||||
<Button
|
||||
variant="outline"
|
||||
w="full"
|
||||
py="8"
|
||||
fontSize="lg"
|
||||
leftIcon={<ToolIcon color="blue.500" boxSize="25px" mr="2" />}
|
||||
onClick={() => handleCreateSubmit()}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
Start from scratch
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
w="full"
|
||||
py="8"
|
||||
fontSize="lg"
|
||||
leftIcon={<TemplateIcon color="orange.500" boxSize="25px" mr="2" />}
|
||||
onClick={onOpen}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
Start from a template
|
||||
</Button>
|
||||
<ImportTypebotFromFileButton
|
||||
variant="outline"
|
||||
w="full"
|
||||
py="8"
|
||||
fontSize="lg"
|
||||
leftIcon={<DownloadIcon color="purple.500" boxSize="25px" mr="2" />}
|
||||
isLoading={isLoading}
|
||||
onNewTypebot={handleCreateSubmit}
|
||||
>
|
||||
Import a file
|
||||
</ImportTypebotFromFileButton>
|
||||
</Stack>
|
||||
<TemplatesModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
onTypebotChoose={handleCreateSubmit}
|
||||
/>
|
||||
</VStack>
|
||||
)
|
||||
}
|
@ -0,0 +1,43 @@
|
||||
import { useToast } from '@/hooks/useToast'
|
||||
import { readFile } from '@/utils/helpers'
|
||||
import { Button, ButtonProps, chakra } from '@chakra-ui/react'
|
||||
import { Typebot } from 'models'
|
||||
import React, { ChangeEvent } from 'react'
|
||||
|
||||
type Props = {
|
||||
onNewTypebot: (typebot: Typebot) => void
|
||||
} & ButtonProps
|
||||
|
||||
export const ImportTypebotFromFileButton = ({
|
||||
onNewTypebot,
|
||||
...props
|
||||
}: Props) => {
|
||||
const { showToast } = useToast()
|
||||
|
||||
const handleInputChange = async (e: ChangeEvent<HTMLInputElement>) => {
|
||||
if (!e.target?.files) return
|
||||
const file = e.target.files[0]
|
||||
const fileContent = await readFile(file)
|
||||
try {
|
||||
onNewTypebot(JSON.parse(fileContent))
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
showToast({ description: 'Failed to parse the file' })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<chakra.input
|
||||
type="file"
|
||||
id="file-input"
|
||||
display="none"
|
||||
onChange={handleInputChange}
|
||||
accept=".json"
|
||||
/>
|
||||
<Button as="label" htmlFor="file-input" cursor="pointer" {...props}>
|
||||
{props.children}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
@ -0,0 +1,152 @@
|
||||
import {
|
||||
Button,
|
||||
chakra,
|
||||
Divider,
|
||||
Heading,
|
||||
HStack,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalOverlay,
|
||||
Stack,
|
||||
Tooltip,
|
||||
} from '@chakra-ui/react'
|
||||
import { ExternalLinkIcon } from '@/components/icons'
|
||||
import { TypebotViewer } from 'bot-engine'
|
||||
import { Typebot } from 'models'
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import { getViewerUrl, sendRequest } from 'utils'
|
||||
import { templates } from '../data'
|
||||
import { TemplateProps } from '../types'
|
||||
import { useToast } from '@/hooks/useToast'
|
||||
import { parseTypebotToPublicTypebot } from '@/features/publish'
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onTypebotChoose: (typebot: Typebot) => void
|
||||
}
|
||||
|
||||
export const TemplatesModal = ({ isOpen, onClose, onTypebotChoose }: Props) => {
|
||||
const [typebot, setTypebot] = useState<Typebot>()
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<TemplateProps>(
|
||||
templates[0]
|
||||
)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const { showToast } = useToast()
|
||||
|
||||
const fetchTemplate = useCallback(
|
||||
async (template: TemplateProps) => {
|
||||
setSelectedTemplate(template)
|
||||
const { data, error } = await sendRequest(
|
||||
`/templates/${template.fileName}`
|
||||
)
|
||||
if (error)
|
||||
return showToast({ title: error.name, description: error.message })
|
||||
setTypebot(data as Typebot)
|
||||
},
|
||||
[showToast]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
fetchTemplate(templates[0])
|
||||
}, [fetchTemplate])
|
||||
|
||||
const onUseThisTemplateClick = () => {
|
||||
if (!typebot) return
|
||||
onTypebotChoose(typebot)
|
||||
setIsLoading(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size="6xl"
|
||||
blockScrollOnMount={false}
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent h="85vh">
|
||||
<ModalBody as={HStack} p="0">
|
||||
<Stack w="full" h="full">
|
||||
<Heading pl="10" pt="4" fontSize="2xl">
|
||||
{selectedTemplate.emoji}{' '}
|
||||
<chakra.span ml="2">{selectedTemplate.name}</chakra.span>
|
||||
</Heading>
|
||||
{typebot && (
|
||||
<TypebotViewer
|
||||
apiHost={getViewerUrl({ isBuilder: true })}
|
||||
typebot={parseTypebotToPublicTypebot(typebot)}
|
||||
key={typebot.id}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Stack
|
||||
h="full"
|
||||
py="6"
|
||||
w="300px"
|
||||
px="4"
|
||||
borderLeftWidth={1}
|
||||
justify="space-between"
|
||||
>
|
||||
<Stack spacing={4}>
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
onClick={onUseThisTemplateClick}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
Use this template
|
||||
</Button>
|
||||
<Divider />
|
||||
<Stack>
|
||||
{templates.map((template) => (
|
||||
<Tooltip
|
||||
key={template.name}
|
||||
isDisabled={!template.isComingSoon}
|
||||
label="Coming soon!"
|
||||
>
|
||||
<span>
|
||||
<Button
|
||||
onClick={() => fetchTemplate(template)}
|
||||
w="full"
|
||||
variant={
|
||||
selectedTemplate.name === template.name
|
||||
? 'solid'
|
||||
: 'ghost'
|
||||
}
|
||||
isDisabled={template.isComingSoon}
|
||||
>
|
||||
{template.emoji}{' '}
|
||||
<chakra.span minW="200px" textAlign="left" ml="3">
|
||||
{template.name}
|
||||
</chakra.span>
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Stack>
|
||||
<Divider />
|
||||
<Tooltip label="Coming soon!" placement="top">
|
||||
<span>
|
||||
<Button
|
||||
w="full"
|
||||
variant="ghost"
|
||||
isDisabled
|
||||
leftIcon={<ExternalLinkIcon />}
|
||||
>
|
||||
Community templates
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
@ -0,0 +1,12 @@
|
||||
import { Seo } from '@/components/Seo'
|
||||
import { DashboardHeader } from '@/features/dashboard'
|
||||
import { VStack } from '@chakra-ui/react'
|
||||
import { CreateNewTypebotButtons } from './CreateNewTypebotButtons'
|
||||
|
||||
export const TemplatesPage = () => (
|
||||
<VStack>
|
||||
<Seo title="Templates" />
|
||||
<DashboardHeader />
|
||||
<CreateNewTypebotButtons />
|
||||
</VStack>
|
||||
)
|
30
apps/builder/src/features/templates/data.ts
Normal file
30
apps/builder/src/features/templates/data.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { TemplateProps } from './types'
|
||||
|
||||
export const templates: TemplateProps[] = [
|
||||
{ name: 'Lead Generation', emoji: '🤝', fileName: 'lead-gen.json' },
|
||||
{ name: 'Customer Support', emoji: '😍', fileName: 'customer-support.json' },
|
||||
{ name: 'Quiz', emoji: '🕹️', fileName: 'quiz.json' },
|
||||
{ name: 'Lead Scoring', emoji: '🏆', fileName: 'lead-scoring.json' },
|
||||
{
|
||||
name: 'Digital Product Payment',
|
||||
emoji: '🖼️',
|
||||
fileName: 'digital-product-payment.json',
|
||||
},
|
||||
{
|
||||
name: 'FAQ',
|
||||
emoji: '💬',
|
||||
fileName: 'faq.json',
|
||||
},
|
||||
{
|
||||
name: 'Conversational Resume',
|
||||
emoji: '👨💼',
|
||||
fileName: 'customer-support.json',
|
||||
isComingSoon: true,
|
||||
},
|
||||
{
|
||||
name: 'User Onboarding',
|
||||
emoji: '🧑🚀',
|
||||
fileName: 'customer-support.json',
|
||||
isComingSoon: true,
|
||||
},
|
||||
]
|
1
apps/builder/src/features/templates/index.ts
Normal file
1
apps/builder/src/features/templates/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { TemplatesPage } from './components/TemplatesPage'
|
35
apps/builder/src/features/templates/templates.spec.ts
Normal file
35
apps/builder/src/features/templates/templates.spec.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { getTestAsset } from '@/test/utils/playwright'
|
||||
import test, { expect } from '@playwright/test'
|
||||
import { typebotViewer } from 'utils/playwright/testHelpers'
|
||||
|
||||
test.describe.parallel('Templates page', () => {
|
||||
test('From scratch should create a blank typebot', async ({ page }) => {
|
||||
await page.goto('/typebots/create')
|
||||
await expect(
|
||||
page.locator('button >> text="Settings & Members"')
|
||||
).toBeEnabled()
|
||||
await page.click('text=Start from scratch')
|
||||
await expect(page).toHaveURL(new RegExp(`/edit`))
|
||||
})
|
||||
|
||||
test('From file should import correctly', async ({ page }) => {
|
||||
await page.goto('/typebots/create')
|
||||
await page.waitForTimeout(1000)
|
||||
await page.setInputFiles(
|
||||
'input[type="file"]',
|
||||
getTestAsset('typebots/singleChoiceTarget.json')
|
||||
)
|
||||
await expect(page).toHaveURL(new RegExp(`/edit`))
|
||||
})
|
||||
|
||||
test('Templates should be previewable and usable', async ({ page }) => {
|
||||
await page.goto('/typebots/create')
|
||||
await page.click('text=Start from a template')
|
||||
await page.click('text=Customer Support')
|
||||
await expect(
|
||||
typebotViewer(page).locator('text=How can I help you?')
|
||||
).toBeVisible()
|
||||
await page.click('text=Use this template')
|
||||
await expect(page).toHaveURL(new RegExp(`/edit`))
|
||||
})
|
||||
})
|
6
apps/builder/src/features/templates/types.ts
Normal file
6
apps/builder/src/features/templates/types.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export type TemplateProps = {
|
||||
name: string
|
||||
emoji: string
|
||||
fileName: string
|
||||
isComingSoon?: boolean
|
||||
}
|
Reference in New Issue
Block a user