(theme) Add theme templates

Allows you to save your themes and select a theme from Typebot's gallery

Closes #275
This commit is contained in:
Baptiste Arnaud
2023-03-28 15:10:06 +02:00
parent c1cf817127
commit 38ed5758fe
49 changed files with 2066 additions and 116 deletions

View File

@@ -0,0 +1,55 @@
import prisma from '@/lib/prisma'
import { authenticatedProcedure } from '@/helpers/server/trpc'
import { TRPCError } from '@trpc/server'
import { ThemeTemplate, themeTemplateSchema } from '@typebot.io/schemas'
import { z } from 'zod'
import { getUserRoleInWorkspace } from '@/features/workspace/helpers/getUserRoleInWorkspace'
import { WorkspaceRole } from '@typebot.io/prisma'
export const deleteThemeTemplate = authenticatedProcedure
.meta({
openapi: {
method: 'DELETE',
path: '/themeTemplates/{themeTemplateId}',
protect: true,
summary: 'Delete a theme template',
tags: ['Workspace', 'Theme'],
},
})
.input(
z.object({
workspaceId: z.string(),
themeTemplateId: z.string(),
})
)
.output(
z.object({
themeTemplate: themeTemplateSchema,
})
)
.mutation(
async ({ input: { themeTemplateId, workspaceId }, ctx: { user } }) => {
const workspace = await prisma.workspace.findUnique({
where: { id: workspaceId },
select: {
members: true,
},
})
const userRole = getUserRoleInWorkspace(user.id, workspace?.members)
if (userRole === undefined || userRole === WorkspaceRole.GUEST)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Workspace not found',
})
const themeTemplate = (await prisma.themeTemplate.delete({
where: {
id: themeTemplateId,
},
})) as ThemeTemplate
return {
themeTemplate,
}
}
)

View File

@@ -0,0 +1,56 @@
import prisma from '@/lib/prisma'
import { authenticatedProcedure } from '@/helpers/server/trpc'
import { TRPCError } from '@trpc/server'
import { ThemeTemplate, themeTemplateSchema } from '@typebot.io/schemas'
import { z } from 'zod'
import { getUserRoleInWorkspace } from '@/features/workspace/helpers/getUserRoleInWorkspace'
import { WorkspaceRole } from '@typebot.io/prisma'
export const listThemeTemplates = authenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/themeTemplates',
protect: true,
summary: 'List theme templates',
tags: ['Workspace', 'Theme'],
},
})
.input(z.object({ workspaceId: z.string() }))
.output(
z.object({
themeTemplates: z.array(
themeTemplateSchema.pick({
id: true,
name: true,
theme: true,
})
),
})
)
.query(async ({ input: { workspaceId }, ctx: { user } }) => {
const workspace = await prisma.workspace.findUnique({
where: { id: workspaceId },
select: {
members: true,
},
})
const userRole = getUserRoleInWorkspace(user.id, workspace?.members)
if (userRole === undefined || userRole === WorkspaceRole.GUEST)
throw new TRPCError({ code: 'NOT_FOUND', message: 'Workspace not found' })
const themeTemplates = (await prisma.themeTemplate.findMany({
where: {
workspaceId,
},
select: {
id: true,
name: true,
theme: true,
},
orderBy: {
createdAt: 'desc',
},
})) as Pick<ThemeTemplate, 'id' | 'name' | 'theme'>[]
return { themeTemplates }
})

View File

@@ -0,0 +1,10 @@
import { router } from '@/helpers/server/trpc'
import { deleteThemeTemplate } from './deleteThemeTemplate'
import { listThemeTemplates } from './listThemeTemplates'
import { saveThemeTemplate } from './saveThemeTemplate'
export const themeRouter = router({
listThemeTemplates,
saveThemeTemplate,
deleteThemeTemplate,
})

View File

@@ -0,0 +1,63 @@
import prisma from '@/lib/prisma'
import { authenticatedProcedure } from '@/helpers/server/trpc'
import { TRPCError } from '@trpc/server'
import { ThemeTemplate, themeTemplateSchema } from '@typebot.io/schemas'
import { z } from 'zod'
import { getUserRoleInWorkspace } from '@/features/workspace/helpers/getUserRoleInWorkspace'
import { WorkspaceRole } from '@typebot.io/prisma'
export const saveThemeTemplate = authenticatedProcedure
.meta({
openapi: {
method: 'PUT',
path: '/themeTemplates/{themeTemplateId}',
protect: true,
summary: 'Save theme template',
tags: ['Workspace', 'Theme'],
},
})
.input(
z.object({
workspaceId: z.string(),
themeTemplateId: z.string(),
name: themeTemplateSchema.shape.name,
theme: themeTemplateSchema.shape.theme,
})
)
.output(
z.object({
themeTemplate: themeTemplateSchema,
})
)
.mutation(
async ({
input: { themeTemplateId, workspaceId, ...data },
ctx: { user },
}) => {
const workspace = await prisma.workspace.findUnique({
where: { id: workspaceId },
select: {
members: true,
},
})
const userRole = getUserRoleInWorkspace(user.id, workspace?.members)
if (userRole === undefined || userRole === WorkspaceRole.GUEST)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Workspace not found',
})
const themeTemplate = (await prisma.themeTemplate.upsert({
where: { id: themeTemplateId },
create: {
...data,
workspaceId,
},
update: data,
})) as ThemeTemplate
return {
themeTemplate,
}
}
)

View File

@@ -9,7 +9,7 @@ type Props = {
export const CustomCssSettings = ({ customCss, onCustomCssChange }: Props) => {
return (
<CodeEditor
defaultValue={customCss ?? ''}
value={customCss ?? ''}
lang="css"
onChange={onCustomCssChange}
/>

View File

@@ -0,0 +1,69 @@
import { SaveIcon } from '@/components/icons'
import { trpc } from '@/lib/trpc'
import { Button, SimpleGrid, Stack, useDisclosure } from '@chakra-ui/react'
import { ThemeTemplate } from '@typebot.io/schemas'
import { areThemesEqual } from '../helpers/areThemesEqual'
import { SaveThemeModal } from './SaveThemeModal'
import { ThemeTemplateCard } from './ThemeTemplateCard'
type Props = {
selectedTemplateId: string | undefined
currentTheme: ThemeTemplate['theme']
workspaceId: string
onTemplateSelect: (
template: Partial<Pick<ThemeTemplate, 'id' | 'theme'>>
) => void
}
export const MyTemplates = ({
selectedTemplateId,
currentTheme,
workspaceId,
onTemplateSelect,
}: Props) => {
const { isOpen, onOpen, onClose } = useDisclosure()
const { data } = trpc.theme.listThemeTemplates.useQuery({
workspaceId,
})
const selectedTemplate = data?.themeTemplates.find(
(themeTemplate) => themeTemplate.id === selectedTemplateId
)
const closeModalAndSelectTemplate = (
template?: Pick<ThemeTemplate, 'id' | 'theme'>
) => {
if (template) onTemplateSelect(template)
onClose()
}
return (
<Stack spacing={4}>
{(!selectedTemplate ||
!areThemesEqual(selectedTemplate?.theme, currentTheme)) && (
<Button leftIcon={<SaveIcon />} onClick={onOpen} colorScheme="blue">
Save current theme
</Button>
)}
<SaveThemeModal
workspaceId={workspaceId}
selectedTemplate={selectedTemplate}
isOpen={isOpen}
onClose={closeModalAndSelectTemplate}
theme={currentTheme}
/>
<SimpleGrid columns={2} spacing={4}>
{data?.themeTemplates.map((themeTemplate) => (
<ThemeTemplateCard
key={themeTemplate.id}
workspaceId={workspaceId}
themeTemplate={themeTemplate}
isSelected={themeTemplate.id === selectedTemplateId}
onClick={() => onTemplateSelect(themeTemplate)}
onRenameClick={onOpen}
onDeleteSuccess={() => onTemplateSelect({ id: '' })}
/>
))}
</SimpleGrid>
</Stack>
)
}

View File

@@ -0,0 +1,109 @@
import { TextInput } from '@/components/inputs'
import { useToast } from '@/hooks/useToast'
import { trpc } from '@/lib/trpc'
import {
Button,
HStack,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
} from '@chakra-ui/react'
import { createId } from '@paralleldrive/cuid2'
import { ThemeTemplate } from '@typebot.io/schemas'
import { FormEvent, useRef, useState } from 'react'
type Props = {
workspaceId: string
isOpen: boolean
onClose: (template?: Pick<ThemeTemplate, 'id' | 'theme'>) => void
selectedTemplate: Pick<ThemeTemplate, 'id' | 'name'> | undefined
theme: ThemeTemplate['theme']
}
export const SaveThemeModal = ({
workspaceId,
isOpen,
onClose,
selectedTemplate,
theme,
}: Props) => {
const { showToast } = useToast()
const [isSaving, setIsSaving] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const {
theme: {
listThemeTemplates: { refetch: refetchThemeTemplates },
},
} = trpc.useContext()
const { mutate } = trpc.theme.saveThemeTemplate.useMutation({
onMutate: () => setIsSaving(true),
onSettled: () => setIsSaving(false),
onSuccess: ({ themeTemplate }) => {
refetchThemeTemplates()
onClose(themeTemplate)
},
onError: (error) => {
showToast({
description: error.message,
})
},
})
const updateExistingTemplate = (e: FormEvent) => {
e.preventDefault()
const newName = inputRef.current?.value
if (!newName) return
mutate({
name: newName,
theme,
workspaceId,
themeTemplateId: selectedTemplate?.id ?? createId(),
})
}
const saveNewTemplate = () => {
const newName = inputRef.current?.value
if (!newName) return
mutate({
name: newName,
theme,
workspaceId,
themeTemplateId: createId(),
})
}
return (
<Modal isOpen={isOpen} onClose={onClose} initialFocusRef={inputRef}>
<ModalOverlay />
<ModalContent as="form" onSubmit={updateExistingTemplate}>
<ModalHeader>Save theme</ModalHeader>
<ModalCloseButton />
<ModalBody>
<TextInput
ref={inputRef}
label="Name:"
defaultValue={selectedTemplate?.name}
withVariableButton={false}
placeholder="My template"
isRequired
/>
</ModalBody>
<ModalFooter as={HStack}>
{selectedTemplate?.id && (
<Button isLoading={isSaving} onClick={saveNewTemplate}>
Save as new template
</Button>
)}
<Button type="submit" colorScheme="blue" isLoading={isSaving}>
{selectedTemplate?.id ? 'Update' : 'Save'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}

View File

@@ -0,0 +1,33 @@
import { SimpleGrid, Stack } from '@chakra-ui/react'
import { ThemeTemplate } from '@typebot.io/schemas'
import { galleryTemplates } from '../galleryTemplates'
import { ThemeTemplateCard } from './ThemeTemplateCard'
type Props = {
selectedTemplateId: string | undefined
currentTheme: ThemeTemplate['theme']
workspaceId: string
onTemplateSelect: (
template: Partial<Pick<ThemeTemplate, 'id' | 'theme'>>
) => void
}
export const TemplatesGallery = ({
selectedTemplateId,
workspaceId,
onTemplateSelect,
}: Props) => (
<Stack spacing={4}>
<SimpleGrid columns={2} spacing={4}>
{galleryTemplates.map((themeTemplate) => (
<ThemeTemplateCard
key={themeTemplate.id}
workspaceId={workspaceId}
themeTemplate={themeTemplate}
isSelected={themeTemplate.id === selectedTemplateId}
onClick={() => onTemplateSelect(themeTemplate)}
/>
))}
</SimpleGrid>
</Stack>
)

View File

@@ -7,28 +7,41 @@ import {
Heading,
HStack,
Stack,
Tag,
} from '@chakra-ui/react'
import { ChatIcon, CodeIcon, PencilIcon } from '@/components/icons'
import { ChatTheme, GeneralTheme } from '@typebot.io/schemas'
import { ChatIcon, CodeIcon, DropletIcon, TableIcon } from '@/components/icons'
import { ChatTheme, GeneralTheme, ThemeTemplate } from '@typebot.io/schemas'
import React from 'react'
import { CustomCssSettings } from './CustomCssSettings'
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
import { headerHeight } from '@/features/editor/constants'
import { ChatThemeSettings } from './chat/ChatThemeSettings'
import { GeneralSettings } from './general/GeneralSettings'
import { ThemeTemplates } from './ThemeTemplates'
export const ThemeSideMenu = () => {
const { typebot, updateTypebot } = useTypebot()
const handleChatThemeChange = (chat: ChatTheme) =>
const updateChatTheme = (chat: ChatTheme) =>
typebot && updateTypebot({ theme: { ...typebot.theme, chat } })
const handleGeneralThemeChange = (general: GeneralTheme) =>
const updateGeneralTheme = (general: GeneralTheme) =>
typebot && updateTypebot({ theme: { ...typebot.theme, general } })
const handleCustomCssChange = (customCss: string) =>
const updateCustomCss = (customCss: string) =>
typebot && updateTypebot({ theme: { ...typebot.theme, customCss } })
const selectedTemplate = (
selectedTemplate: Partial<Pick<ThemeTemplate, 'id' | 'theme'>>
) => {
if (!typebot) return
const { theme, id } = selectedTemplate
updateTypebot({
selectedThemeTemplateId: id,
theme: theme ? { ...theme } : typebot.theme,
})
}
return (
<Stack
flex="1"
@@ -48,8 +61,33 @@ export const ThemeSideMenu = () => {
<AccordionItem>
<AccordionButton py={6}>
<HStack flex="1" pl={2}>
<PencilIcon />
<Heading fontSize="lg">General</Heading>
<TableIcon />
<Heading fontSize="lg">
<HStack>
<span>Templates</span> <Tag colorScheme="orange">New!</Tag>
</HStack>
</Heading>
</HStack>
<AccordionIcon />
</AccordionButton>
<AccordionPanel pb={4}>
{typebot && (
<ThemeTemplates
selectedTemplateId={
typebot.selectedThemeTemplateId ?? undefined
}
currentTheme={typebot.theme}
workspaceId={typebot.workspaceId}
onTemplateSelect={selectedTemplate}
/>
)}
</AccordionPanel>
</AccordionItem>
<AccordionItem>
<AccordionButton py={6}>
<HStack flex="1" pl={2}>
<DropletIcon />
<Heading fontSize="lg">Font & Background</Heading>
</HStack>
<AccordionIcon />
</AccordionButton>
@@ -57,7 +95,7 @@ export const ThemeSideMenu = () => {
{typebot && (
<GeneralSettings
generalTheme={typebot.theme.general}
onGeneralThemeChange={handleGeneralThemeChange}
onGeneralThemeChange={updateGeneralTheme}
/>
)}
</AccordionPanel>
@@ -75,7 +113,7 @@ export const ThemeSideMenu = () => {
<ChatThemeSettings
typebotId={typebot.id}
chatTheme={typebot.theme.chat}
onChatThemeChange={handleChatThemeChange}
onChatThemeChange={updateChatTheme}
/>
)}
</AccordionPanel>
@@ -92,7 +130,7 @@ export const ThemeSideMenu = () => {
{typebot && (
<CustomCssSettings
customCss={typebot.theme.customCss}
onCustomCssChange={handleCustomCssChange}
onCustomCssChange={updateCustomCss}
/>
)}
</AccordionPanel>

View File

@@ -0,0 +1,223 @@
import { MoreHorizontalIcon, EditIcon, TrashIcon } from '@/components/icons'
import { colors } from '@/lib/theme'
import { trpc } from '@/lib/trpc'
import {
Stack,
HStack,
Flex,
Menu,
MenuButton,
IconButton,
MenuList,
MenuItem,
Box,
Text,
Image,
} from '@chakra-ui/react'
import { BackgroundType, ThemeTemplate } from '@typebot.io/schemas'
import { useState } from 'react'
import { DefaultAvatar } from './DefaultAvatar'
export const ThemeTemplateCard = ({
workspaceId,
themeTemplate,
isSelected,
onClick,
onRenameClick,
onDeleteSuccess,
}: {
workspaceId: string
themeTemplate: Pick<ThemeTemplate, 'name' | 'theme' | 'id'>
isSelected: boolean
onRenameClick?: () => void
onClick: () => void
onDeleteSuccess?: () => void
}) => {
const [isDeleting, setIsDeleting] = useState(false)
const {
theme: {
listThemeTemplates: { refetch: refetchThemeTemplates },
},
} = trpc.useContext()
const { mutate } = trpc.theme.deleteThemeTemplate.useMutation({
onMutate: () => setIsDeleting(true),
onSettled: () => setIsDeleting(false),
onSuccess: () => {
refetchThemeTemplates()
if (onDeleteSuccess) onDeleteSuccess()
},
})
const deleteThemeTemplate = () => {
mutate({ themeTemplateId: themeTemplate.id, workspaceId })
}
const rounded =
themeTemplate.theme.chat.roundness === 'large'
? 'md'
: themeTemplate.theme.chat.roundness === 'none'
? 'none'
: 'sm'
return (
<Stack
cursor="pointer"
onClick={onClick}
spacing={0}
opacity={isDeleting ? 0.5 : 1}
pointerEvents={isDeleting ? 'none' : undefined}
rounded="md"
boxShadow={
isSelected
? `${colors['blue']['400']} 0 0 0 4px`
: `rgba(0, 0, 0, 0.08) 0px 2px 4px`
}
style={{
willChange: 'box-shadow',
transition: 'box-shadow 0.2s ease 0s',
}}
>
<Box
borderTopRadius="md"
backgroundSize="cover"
{...parseBackground(themeTemplate.theme.general.background)}
borderColor={isSelected ? 'blue.400' : undefined}
>
<HStack mt="4" ml="4" spacing={0.5} alignItems="flex-end">
<AvatarPreview avatar={themeTemplate.theme.chat.hostAvatar} />
<Box
rounded="sm"
w="80px"
h="16px"
background={themeTemplate.theme.chat.hostBubbles.backgroundColor}
/>
</HStack>
<HStack
mt="1"
mr="4"
ml="auto"
justifyContent="flex-end"
alignItems="flex-end"
>
<Box
rounded="sm"
w="80px"
h="16px"
background={themeTemplate.theme.chat.guestBubbles.backgroundColor}
/>
<AvatarPreview avatar={themeTemplate.theme.chat.guestAvatar} />
</HStack>
<HStack mt="1" ml="4" spacing={0.5} alignItems="flex-end">
<AvatarPreview avatar={themeTemplate.theme.chat.hostAvatar} />
<Box
rounded="sm"
w="80px"
h="16px"
background={themeTemplate.theme.chat.hostBubbles.backgroundColor}
/>
</HStack>
<Flex
mt="1"
mb="4"
pr="4"
ml="auto"
w="full"
justifyContent="flex-end"
gap="1"
>
<Box
rounded={rounded}
w="20px"
h="10px"
background={themeTemplate.theme.chat.buttons.backgroundColor}
/>
<Box
rounded={rounded}
w="20px"
h="10px"
background={themeTemplate.theme.chat.buttons.backgroundColor}
/>
<Box
rounded={rounded}
w="20px"
h="10px"
background={themeTemplate.theme.chat.buttons.backgroundColor}
/>
</Flex>
</Box>
<HStack p="2" justifyContent="space-between">
<Text fontSize="sm" noOfLines={1}>
{themeTemplate.name}
</Text>
{onDeleteSuccess && onRenameClick && (
<Menu isLazy>
<MenuButton
as={IconButton}
icon={<MoreHorizontalIcon />}
aria-label="Open template menu"
variant="ghost"
size="xs"
onClick={(e) => e.stopPropagation()}
/>
<MenuList onClick={(e) => e.stopPropagation()}>
{isSelected && (
<MenuItem icon={<EditIcon />} onClick={onRenameClick}>
Rename
</MenuItem>
)}
<MenuItem
icon={<TrashIcon />}
color="red.500"
onClick={deleteThemeTemplate}
>
Delete
</MenuItem>
</MenuList>
</Menu>
)}
</HStack>
</Stack>
)
}
const parseBackground = (background: {
type: BackgroundType
content?: string
}) => {
switch (background.type) {
case BackgroundType.COLOR:
return {
backgroundColor: background.content,
}
case BackgroundType.IMAGE:
return { backgroundImage: `url(${background.content})` }
case BackgroundType.NONE:
return
}
}
const AvatarPreview = ({
avatar,
}: {
avatar:
| {
isEnabled: boolean
url?: string | undefined
}
| undefined
}) => {
if (!avatar?.isEnabled) return null
return avatar.url ? (
<Image
src={avatar.url}
alt="Avatar preview in theme template card"
boxSize="12px"
rounded="full"
/>
) : (
<DefaultAvatar boxSize="12px" />
)
}

View File

@@ -0,0 +1,86 @@
import { Button, HStack, Stack } from '@chakra-ui/react'
import { ThemeTemplate } from '@typebot.io/schemas'
import { useState } from 'react'
import { MyTemplates } from './MyTemplates'
import { TemplatesGallery } from './TemplatesGallery'
type Tab = 'my-templates' | 'gallery'
type Props = {
workspaceId: string
selectedTemplateId: string | undefined
currentTheme: ThemeTemplate['theme']
onTemplateSelect: (
template: Partial<Pick<ThemeTemplate, 'id' | 'theme'>>
) => void
}
export const ThemeTemplates = ({
workspaceId,
selectedTemplateId,
currentTheme,
onTemplateSelect,
}: Props) => {
const [selectedTab, setSelectedTab] = useState<Tab>('my-templates')
return (
<Stack spacing={4} pb={12}>
<HStack>
<Button
flex="1"
variant="outline"
colorScheme={selectedTab === 'my-templates' ? 'blue' : 'gray'}
onClick={() => setSelectedTab('my-templates')}
>
My templates
</Button>
<Button
flex="1"
variant="outline"
colorScheme={selectedTab === 'gallery' ? 'blue' : 'gray'}
onClick={() => setSelectedTab('gallery')}
>
Gallery
</Button>
</HStack>
<ThemeTemplatesBody
tab={selectedTab}
currentTheme={currentTheme}
workspaceId={workspaceId}
selectedTemplateId={selectedTemplateId}
onTemplateSelect={onTemplateSelect}
/>
</Stack>
)
}
const ThemeTemplatesBody = ({
tab,
workspaceId,
selectedTemplateId,
currentTheme,
onTemplateSelect,
}: {
tab: Tab
} & Props) => {
switch (tab) {
case 'my-templates':
return (
<MyTemplates
onTemplateSelect={onTemplateSelect}
currentTheme={currentTheme}
selectedTemplateId={selectedTemplateId}
workspaceId={workspaceId}
/>
)
case 'gallery':
return (
<TemplatesGallery
onTemplateSelect={onTemplateSelect}
currentTheme={currentTheme}
selectedTemplateId={selectedTemplateId}
workspaceId={workspaceId}
/>
)
}
}

View File

@@ -19,16 +19,13 @@ export const ButtonsTheme = ({ buttons, onButtonsChange }: Props) => {
<Flex justify="space-between" align="center">
<Text>Background:</Text>
<ColorPicker
initialColor={buttons.backgroundColor}
value={buttons.backgroundColor}
onColorChange={handleBackgroundChange}
/>
</Flex>
<Flex justify="space-between" align="center">
<Text>Text:</Text>
<ColorPicker
initialColor={buttons.color}
onColorChange={handleTextChange}
/>
<ColorPicker value={buttons.color} onColorChange={handleTextChange} />
</Flex>
</Stack>
)

View File

@@ -65,29 +65,7 @@ export const ChatThemeSettings = ({
onHostBubblesChange={handleHostBubblesChange}
/>
</Stack>
<Stack borderWidth={1} rounded="md" p="4" spacing={4}>
<Heading fontSize="lg">Corners roundness</Heading>
<RadioButtons
options={[
{
label: <NoRadiusIcon />,
value: 'none',
},
{
label: <MediumRadiusIcon />,
value: 'medium',
},
{
label: <LargeRadiusIcon />,
value: 'large',
},
]}
defaultValue={chatTheme.roundness ?? 'medium'}
onSelect={(roundness) =>
onChatThemeChange({ ...chatTheme, roundness })
}
/>
</Stack>
<Stack borderWidth={1} rounded="md" p="4" spacing={4}>
<Heading fontSize="lg">User bubbles</Heading>
<GuestBubbles
@@ -109,6 +87,29 @@ export const ChatThemeSettings = ({
onInputsChange={handleInputsChange}
/>
</Stack>
<Stack borderWidth={1} rounded="md" p="4" spacing={4}>
<Heading fontSize="lg">Corners roundness</Heading>
<RadioButtons
options={[
{
label: <NoRadiusIcon />,
value: 'none',
},
{
label: <MediumRadiusIcon />,
value: 'medium',
},
{
label: <LargeRadiusIcon />,
value: 'large',
},
]}
value={chatTheme.roundness ?? 'medium'}
onSelect={(roundness) =>
onChatThemeChange({ ...chatTheme, roundness })
}
/>
</Stack>
</Stack>
)
}

View File

@@ -19,14 +19,14 @@ export const GuestBubbles = ({ guestBubbles, onGuestBubblesChange }: Props) => {
<Flex justify="space-between" align="center">
<Text>Background:</Text>
<ColorPicker
initialColor={guestBubbles.backgroundColor}
value={guestBubbles.backgroundColor}
onColorChange={handleBackgroundChange}
/>
</Flex>
<Flex justify="space-between" align="center">
<Text>Text:</Text>
<ColorPicker
initialColor={guestBubbles.color}
value={guestBubbles.color}
onColorChange={handleTextChange}
/>
</Flex>

View File

@@ -19,14 +19,14 @@ export const HostBubbles = ({ hostBubbles, onHostBubblesChange }: Props) => {
<Flex justify="space-between" align="center">
<Text>Background:</Text>
<ColorPicker
initialColor={hostBubbles.backgroundColor}
value={hostBubbles.backgroundColor}
onColorChange={handleBackgroundChange}
/>
</Flex>
<Flex justify="space-between" align="center">
<Text>Text:</Text>
<ColorPicker
initialColor={hostBubbles.color}
value={hostBubbles.color}
onColorChange={handleTextChange}
/>
</Flex>

View File

@@ -21,21 +21,18 @@ export const InputsTheme = ({ inputs, onInputsChange }: Props) => {
<Flex justify="space-between" align="center">
<Text>Background:</Text>
<ColorPicker
initialColor={inputs.backgroundColor}
value={inputs.backgroundColor}
onColorChange={handleBackgroundChange}
/>
</Flex>
<Flex justify="space-between" align="center">
<Text>Text:</Text>
<ColorPicker
initialColor={inputs.color}
onColorChange={handleTextChange}
/>
<ColorPicker value={inputs.color} onColorChange={handleTextChange} />
</Flex>
<Flex justify="space-between" align="center">
<Text>Placeholder text:</Text>
<ColorPicker
initialColor={inputs.placeholderColor}
value={inputs.placeholderColor}
onColorChange={handlePlaceholderChange}
/>
</Flex>

View File

@@ -35,7 +35,7 @@ export const BackgroundContent = ({
<Flex justify="space-between" align="center">
<Text>Background color:</Text>
<ColorPicker
initialColor={background.content ?? defaultBackgroundColor}
value={background.content ?? defaultBackgroundColor}
onColorChange={handleContentChange}
/>
</Flex>

View File

@@ -31,7 +31,7 @@ export const BackgroundSelector = ({
BackgroundType.IMAGE,
BackgroundType.NONE,
]}
defaultValue={background?.type ?? defaultBackgroundType}
value={background?.type ?? defaultBackgroundType}
onSelect={handleBackgroundTypeChange}
/>
<BackgroundContent

View File

@@ -41,7 +41,7 @@ export const FontSelector = ({
<HStack justify="space-between" align="center">
<Text>Font</Text>
<AutocompleteInput
defaultValue={activeFont}
value={activeFont}
items={googleFonts}
onChange={handleFontSelected}
withVariableButton={false}

View File

@@ -0,0 +1,144 @@
import { BackgroundType, ThemeTemplate } from '@typebot.io/schemas'
export const galleryTemplates: Pick<ThemeTemplate, 'id' | 'name' | 'theme'>[] =
[
{
id: 'typebot-light',
name: 'Typebot Light',
theme: {
chat: {
inputs: {
color: '#303235',
backgroundColor: '#FFFFFF',
placeholderColor: '#9095A0',
},
buttons: { color: '#FFFFFF', backgroundColor: '#0042DA' },
hostAvatar: {
isEnabled: true,
},
hostBubbles: { color: '#303235', backgroundColor: '#F7F8FF' },
guestBubbles: { color: '#FFFFFF', backgroundColor: '#FF8E21' },
},
general: {
font: 'Open Sans',
background: { type: BackgroundType.COLOR, content: '#ffffff' },
},
},
},
{
id: 'typebot-dark',
name: 'Typebot Dark',
theme: {
chat: {
inputs: {
color: '#ffffff',
backgroundColor: '#1e293b',
placeholderColor: '#9095A0',
},
buttons: { color: '#ffffff', backgroundColor: '#1a5fff' },
hostAvatar: {
isEnabled: true,
},
hostBubbles: { color: '#ffffff', backgroundColor: '#1e293b' },
guestBubbles: { color: '#FFFFFF', backgroundColor: '#FF8E21' },
},
general: {
font: 'Open Sans',
background: { type: BackgroundType.COLOR, content: '#171923' },
},
},
},
{
id: 'minimalist-black',
name: 'Minimalist Black',
theme: {
chat: {
inputs: {
color: '#303235',
backgroundColor: '#FFFFFF',
placeholderColor: '#9095A0',
},
buttons: { color: '#FFFFFF', backgroundColor: '#303235' },
hostAvatar: { isEnabled: false },
hostBubbles: { color: '#303235', backgroundColor: '#F7F8FF' },
guestBubbles: { color: '#303235', backgroundColor: '#F7F8FF' },
},
general: {
font: 'Inter',
background: { type: BackgroundType.COLOR, content: '#ffffff' },
},
},
},
{
id: 'minimalist-teal',
name: 'Minimalist Teal',
theme: {
chat: {
inputs: {
color: '#303235',
backgroundColor: '#FFFFFF',
placeholderColor: '#9095A0',
},
buttons: { color: '#FFFFFF', backgroundColor: '#0d9488' },
hostAvatar: { isEnabled: false },
hostBubbles: { color: '#303235', backgroundColor: '#F7F8FF' },
guestBubbles: { color: '#303235', backgroundColor: '#F7F8FF' },
},
general: {
font: 'Inter',
background: { type: BackgroundType.COLOR, content: '#ffffff' },
},
},
},
{
id: 'bright-rain',
name: 'Bright Rain',
theme: {
chat: {
inputs: {
color: '#303235',
backgroundColor: '#FFFFFF',
placeholderColor: '#9095A0',
},
buttons: { color: '#fff', backgroundColor: '#D27A7D' },
hostAvatar: { isEnabled: true },
hostBubbles: { color: '#303235', backgroundColor: '#F7F8FF' },
guestBubbles: { color: '#303235', backgroundColor: '#FDDDBF' },
},
general: {
font: 'Montserrat',
background: {
type: BackgroundType.IMAGE,
content:
'https://s3.fr-par.scw.cloud/typebot/public/typebots/hlmywyje0sbz1lfogu86pyks/blocks/ssmyt084oosa17cggqd8kfg9',
},
},
},
},
{
id: 'ray-of-lights',
name: 'Ray of Lights',
theme: {
chat: {
inputs: {
color: '#303235',
backgroundColor: '#FFFFFF',
placeholderColor: '#9095A0',
},
buttons: { color: '#fff', backgroundColor: '#1A2249' },
hostAvatar: { isEnabled: true },
hostBubbles: { color: '#303235', backgroundColor: '#F7F8FF' },
guestBubbles: { color: '#fff', backgroundColor: '#1A2249' },
},
general: {
font: 'Raleway',
background: {
type: BackgroundType.IMAGE,
content:
'https://s3.fr-par.scw.cloud/typebot/public/typebots/hlmywyje0sbz1lfogu86pyks/blocks/uc2dyf63eeogaivqzm4z2hdb',
},
},
},
},
]

View File

@@ -0,0 +1,11 @@
import { ThemeTemplate } from '@typebot.io/schemas'
import { dequal } from 'dequal'
export const areThemesEqual = (
selectedTemplate: ThemeTemplate['theme'],
currentTheme: ThemeTemplate['theme']
) =>
dequal(
JSON.parse(JSON.stringify(selectedTemplate)),
JSON.parse(JSON.stringify(currentTheme))
)

View File

@@ -21,6 +21,7 @@ test.describe.parallel('Theme page', () => {
await expect(page.locator('button >> text="Go"')).toBeVisible()
// Font
await page.getByRole('button', { name: 'Font & Background' }).click()
await page.getByRole('textbox').fill('Roboto Slab')
await expect(page.locator('.typebot-container')).toHaveCSS(
'font-family',
@@ -213,4 +214,40 @@ test.describe.parallel('Theme page', () => {
)
})
})
test.describe('Theme templates', () => {
test('should reflect change in real-time', async ({ page }) => {
const typebotId = createId()
await importTypebotInDatabase(getTestAsset('typebots/theme.json'), {
id: typebotId,
})
await page.goto(`/typebots/${typebotId}/theme`)
await expect(page.locator('button >> text="Go"')).toBeVisible()
await page.getByRole('button', { name: 'Save current theme' }).click()
await page.getByPlaceholder('My template').fill('My awesome theme')
await page.getByRole('button', { name: 'Save' }).click()
await page.getByRole('button', { name: 'Open template menu' }).click()
await page.getByRole('menuitem', { name: 'Rename' }).click()
await expect(page.getByPlaceholder('My template')).toHaveValue(
'My awesome theme'
)
await page.getByPlaceholder('My template').fill('My awesome theme 2')
await page.getByRole('button', { name: 'Save as new template' }).click()
await expect(
page.getByRole('button', { name: 'Open template menu' })
).toHaveCount(2)
await page
.getByRole('button', { name: 'Open template menu' })
.first()
.click()
await page.getByRole('menuitem', { name: 'Delete' }).click()
await expect(page.getByText('My awesome theme 2')).toBeHidden()
await page.getByRole('button', { name: 'Gallery' }).click()
await page.getByText('Typebot Dark').click()
await expect(page.getByTestId('host-bubble')).toHaveCSS(
'background-color',
'rgb(30, 41, 59)'
)
})
})
})