2
0

(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

@@ -13,7 +13,7 @@ import {
Stack,
ButtonProps,
} from '@chakra-ui/react'
import React, { ChangeEvent, useEffect, useState } from 'react'
import React, { ChangeEvent, useState } from 'react'
import tinyColor from 'tinycolor2'
const colorsSelection: `#${string}`[] = [
@@ -29,31 +29,37 @@ const colorsSelection: `#${string}`[] = [
]
type Props = {
initialColor?: string
value?: string
defaultValue?: string
onColorChange: (color: string) => void
}
export const ColorPicker = ({ initialColor, onColorChange }: Props) => {
const [color, setColor] = useState(initialColor ?? '')
export const ColorPicker = ({ value, defaultValue, onColorChange }: Props) => {
const [color, setColor] = useState(defaultValue ?? '')
const displayedValue = value ?? color
useEffect(() => {
onColorChange(color)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [color])
const handleColorChange = (e: ChangeEvent<HTMLInputElement>) =>
const handleColorChange = (e: ChangeEvent<HTMLInputElement>) => {
setColor(e.target.value)
onColorChange(e.target.value)
}
const handleClick = (color: string) => () => setColor(color)
const handleClick = (color: string) => () => {
setColor(color)
onColorChange(color)
}
return (
<Popover variant="picker" placement="right" isLazy>
<PopoverTrigger>
<Button
aria-label={'Pick a color'}
bgColor={color}
_hover={{ bgColor: `#${tinyColor(color).darken(10).toHex()}` }}
_active={{ bgColor: `#${tinyColor(color).darken(30).toHex()}` }}
bgColor={displayedValue}
_hover={{
bgColor: `#${tinyColor(displayedValue).darken(10).toHex()}`,
}}
_active={{
bgColor: `#${tinyColor(displayedValue).darken(30).toHex()}`,
}}
height="22px"
width="22px"
padding={0}
@@ -62,16 +68,16 @@ export const ColorPicker = ({ initialColor, onColorChange }: Props) => {
/>
</PopoverTrigger>
<PopoverContent width="170px">
<PopoverArrow bg={color} />
<PopoverArrow bg={displayedValue} />
<PopoverCloseButton color="white" />
<PopoverHeader
height="100px"
backgroundColor={color}
backgroundColor={displayedValue}
borderTopLeftRadius={5}
borderTopRightRadius={5}
color={tinyColor(color).isLight() ? 'gray.800' : 'white'}
color={tinyColor(displayedValue).isLight() ? 'gray.800' : 'white'}
>
<Center height="100%">{color}</Center>
<Center height="100%">{displayedValue}</Center>
</PopoverHeader>
<PopoverBody as={Stack}>
<SimpleGrid columns={5} spacing={2}>
@@ -96,12 +102,12 @@ export const ColorPicker = ({ initialColor, onColorChange }: Props) => {
placeholder="#2a9d8f"
aria-label="Color value"
size="sm"
value={color}
value={displayedValue}
onChange={handleColorChange}
/>
<NativeColorPicker
size="sm"
color={color}
color={displayedValue}
onColorChange={handleColorChange}
>
Advanced picker

View File

@@ -202,15 +202,6 @@ export const CodeIcon = (props: IconProps) => (
</Icon>
)
export const PencilIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<path d="M12 19l7-7 3 3-7 7-3-3z"></path>
<path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"></path>
<path d="M2 2l7.586 7.586"></path>
<circle cx="11" cy="11" r="2"></circle>
</Icon>
)
export const EditIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"></path>
@@ -591,3 +582,15 @@ export const LargeRadiusIcon = (props: IconProps) => (
/>
</Icon>
)
export const DropletIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<path d="M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z"></path>
</Icon>
)
export const TableIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<path d="M9 3H5a2 2 0 0 0-2 2v4m6-6h10a2 2 0 0 1 2 2v4M9 3v18m0 0h10a2 2 0 0 0 2-2V9M9 21H5a2 2 0 0 1-2-2V9m0 0h18"></path>
</Icon>
)

View File

@@ -24,6 +24,7 @@ import { MoreInfoTooltip } from '../MoreInfoTooltip'
type Props = {
items: string[]
value?: string
defaultValue?: string
debounceTimeout?: number
placeholder?: string
@@ -40,6 +41,7 @@ export const AutocompleteInput = ({
debounceTimeout,
placeholder,
withVariableButton = true,
value,
defaultValue,
label,
moreInfoTooltip,
@@ -178,7 +180,7 @@ export const AutocompleteInput = ({
<Input
autoComplete="off"
ref={inputRef}
value={inputValue}
value={value ?? inputValue}
onChange={(e) => changeValue(e.target.value)}
onFocus={onOpen}
onBlur={updateCarretPosition}

View File

@@ -65,7 +65,6 @@ export const CodeEditor = ({
}
const handleChange = (newValue: string) => {
if (isDefined(props.value)) return
setValue(newValue)
}

View File

@@ -11,15 +11,18 @@ import { ReactNode } from 'react'
type Props<T extends string> = {
options: (T | { value: T; label: ReactNode })[]
defaultValue: T
value?: T
defaultValue?: T
onSelect: (newValue: T) => void
}
export const RadioButtons = <T extends string>({
options,
value,
defaultValue,
onSelect,
}: Props<T>) => {
const { getRootProps, getRadioProps } = useRadioGroup({
value,
defaultValue,
onChange: onSelect,
})

View File

@@ -24,7 +24,7 @@ import { MoreInfoTooltip } from '../MoreInfoTooltip'
export type TextInputProps = {
defaultValue?: string
onChange: (value: string) => void
onChange?: (value: string) => void
debounceTimeout?: number
label?: ReactNode
helperText?: ReactNode
@@ -66,7 +66,8 @@ export const TextInput = forwardRef(function TextInput(
localValue.length ?? 0
)
const onChange = useDebouncedCallback(
_onChange,
// eslint-disable-next-line @typescript-eslint/no-empty-function
_onChange ?? (() => {}),
env('E2E_TEST') === 'true' ? 0 : debounceTimeout
)

View File

@@ -55,6 +55,7 @@ export const parseNewTypebot = ({
groups: [startGroup],
edges: [],
variables: [],
selectedThemeTemplateId: null,
theme: {
...defaultTheme,
chat: {

View File

@@ -1,9 +1,6 @@
import {
LogicBlockType,
PublicTypebot,
ResultsTablePreferences,
Settings,
Theme,
Typebot,
Webhook,
} from '@typebot.io/schemas'
@@ -45,16 +42,20 @@ import { convertPublicTypebotToTypebot } from '@/features/publish/helpers/conver
const autoSaveTimeout = 10000
type UpdateTypebotPayload = Partial<{
theme: Theme
settings: Settings
publicId: string
name: string
icon: string
customDomain: string | null
resultsTablePreferences: ResultsTablePreferences
isClosed: boolean
}>
type UpdateTypebotPayload = Partial<
Pick<
Typebot,
| 'theme'
| 'selectedThemeTemplateId'
| 'settings'
| 'publicId'
| 'name'
| 'icon'
| 'customDomain'
| 'resultsTablePreferences'
| 'isClosed'
>
>
export type SetTypebot = (
newPresent: Typebot | ((current: Typebot) => Typebot)

View File

@@ -37,14 +37,14 @@ export const ButtonThemeSettings = ({ buttonTheme, onChange }: Props) => {
<HStack justify="space-between">
<Text>Background color</Text>
<ColorPicker
initialColor={buttonTheme?.backgroundColor}
defaultValue={buttonTheme?.backgroundColor}
onColorChange={updateBackgroundColor}
/>
</HStack>
<HStack justify="space-between">
<Text>Icon color</Text>
<ColorPicker
initialColor={buttonTheme?.iconColor}
defaultValue={buttonTheme?.iconColor}
onColorChange={updateIconColor}
/>
</HStack>

View File

@@ -49,28 +49,28 @@ export const PreviewMessageThemeSettings = ({
<HStack justify="space-between">
<Text>Background color</Text>
<ColorPicker
initialColor={previewMessageTheme?.backgroundColor}
defaultValue={previewMessageTheme?.backgroundColor}
onColorChange={updateBackgroundColor}
/>
</HStack>
<HStack justify="space-between">
<Text>Text color</Text>
<ColorPicker
initialColor={previewMessageTheme?.textColor}
defaultValue={previewMessageTheme?.textColor}
onColorChange={updateTextColor}
/>
</HStack>
<HStack justify="space-between">
<Text>Close button background</Text>
<ColorPicker
initialColor={previewMessageTheme?.closeButtonBackgroundColor}
defaultValue={previewMessageTheme?.closeButtonBackgroundColor}
onColorChange={updateCloseButtonBackgroundColor}
/>
</HStack>
<HStack justify="space-between">
<Text>Close icon color</Text>
<ColorPicker
initialColor={previewMessageTheme?.closeButtonIconColor}
defaultValue={previewMessageTheme?.closeButtonIconColor}
onColorChange={updateCloseButtonIconColor}
/>
</HStack>

View File

@@ -22,4 +22,5 @@ export const convertPublicTypebotToTypebot = (
isArchived: existingTypebot.isArchived,
isClosed: existingTypebot.isClosed,
resultsTablePreferences: existingTypebot.resultsTablePreferences,
selectedThemeTemplateId: existingTypebot.selectedThemeTemplateId,
})

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

View File

@@ -0,0 +1,6 @@
import { MemberInWorkspace } from '@typebot.io/prisma'
export const getUserRoleInWorkspace = (
userId: string,
workspaceMembers: MemberInWorkspace[] | undefined
) => workspaceMembers?.find((member) => member.userId === userId)?.role

View File

@@ -4,6 +4,7 @@ import { credentialsRouter } from '@/features/credentials/api/router'
import { getAppVersionProcedure } from '@/features/dashboard/api/getAppVersionProcedure'
import { resultsRouter } from '@/features/results/api/router'
import { processTelemetryEvent } from '@/features/telemetry/api/processTelemetryEvent'
import { themeRouter } from '@/features/theme/api/router'
import { typebotRouter } from '@/features/typebot/api/router'
import { workspaceRouter } from '@/features/workspace/api/router'
import { router } from '../../trpc'
@@ -17,6 +18,7 @@ export const trpcRouter = router({
results: resultsRouter,
billing: billingRouter,
credentials: credentialsRouter,
theme: themeRouter,
})
export type AppRouter = typeof trpcRouter

View File

@@ -2716,6 +2716,912 @@
}
}
}
},
"/themeTemplates": {
"get": {
"operationId": "query.theme.listThemeTemplates",
"summary": "List theme templates",
"tags": [
"Workspace",
"Theme"
],
"security": [
{
"Authorization": []
}
],
"parameters": [
{
"name": "workspaceId",
"in": "query",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"themeTemplates": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"theme": {
"type": "object",
"properties": {
"general": {
"type": "object",
"properties": {
"font": {
"type": "string"
},
"background": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": [
"Color",
"Image",
"None"
]
},
"content": {
"type": "string"
}
},
"required": [
"type"
],
"additionalProperties": false
}
},
"required": [
"font",
"background"
],
"additionalProperties": false
},
"chat": {
"type": "object",
"properties": {
"hostAvatar": {
"type": "object",
"properties": {
"isEnabled": {
"type": "boolean"
},
"url": {
"type": "string"
}
},
"required": [
"isEnabled"
],
"additionalProperties": false
},
"guestAvatar": {
"type": "object",
"properties": {
"isEnabled": {
"type": "boolean"
},
"url": {
"type": "string"
}
},
"required": [
"isEnabled"
],
"additionalProperties": false
},
"hostBubbles": {
"type": "object",
"properties": {
"backgroundColor": {
"type": "string"
},
"color": {
"type": "string"
}
},
"required": [
"backgroundColor",
"color"
],
"additionalProperties": false
},
"guestBubbles": {
"type": "object",
"properties": {
"backgroundColor": {
"type": "string"
},
"color": {
"type": "string"
}
},
"required": [
"backgroundColor",
"color"
],
"additionalProperties": false
},
"buttons": {
"type": "object",
"properties": {
"backgroundColor": {
"type": "string"
},
"color": {
"type": "string"
}
},
"required": [
"backgroundColor",
"color"
],
"additionalProperties": false
},
"inputs": {
"type": "object",
"properties": {
"backgroundColor": {
"type": "string"
},
"color": {
"type": "string"
},
"placeholderColor": {
"type": "string"
}
},
"required": [
"backgroundColor",
"color",
"placeholderColor"
],
"additionalProperties": false
},
"roundness": {
"type": "string",
"enum": [
"none",
"medium",
"large"
]
}
},
"required": [
"hostBubbles",
"guestBubbles",
"buttons",
"inputs"
],
"additionalProperties": false
},
"customCss": {
"type": "string"
}
},
"required": [
"general",
"chat"
],
"additionalProperties": false
}
},
"required": [
"id",
"name",
"theme"
],
"additionalProperties": false
}
}
},
"required": [
"themeTemplates"
],
"additionalProperties": false
}
}
}
},
"default": {
"$ref": "#/components/responses/error"
}
}
}
},
"/themeTemplates/{themeTemplateId}": {
"put": {
"operationId": "mutation.theme.saveThemeTemplate",
"summary": "Save theme template",
"tags": [
"Workspace",
"Theme"
],
"security": [
{
"Authorization": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"workspaceId": {
"type": "string"
},
"name": {
"type": "string"
},
"theme": {
"type": "object",
"properties": {
"general": {
"type": "object",
"properties": {
"font": {
"type": "string"
},
"background": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": [
"Color",
"Image",
"None"
]
},
"content": {
"type": "string"
}
},
"required": [
"type"
],
"additionalProperties": false
}
},
"required": [
"font",
"background"
],
"additionalProperties": false
},
"chat": {
"type": "object",
"properties": {
"hostAvatar": {
"type": "object",
"properties": {
"isEnabled": {
"type": "boolean"
},
"url": {
"type": "string"
}
},
"required": [
"isEnabled"
],
"additionalProperties": false
},
"guestAvatar": {
"type": "object",
"properties": {
"isEnabled": {
"type": "boolean"
},
"url": {
"type": "string"
}
},
"required": [
"isEnabled"
],
"additionalProperties": false
},
"hostBubbles": {
"type": "object",
"properties": {
"backgroundColor": {
"type": "string"
},
"color": {
"type": "string"
}
},
"required": [
"backgroundColor",
"color"
],
"additionalProperties": false
},
"guestBubbles": {
"type": "object",
"properties": {
"backgroundColor": {
"type": "string"
},
"color": {
"type": "string"
}
},
"required": [
"backgroundColor",
"color"
],
"additionalProperties": false
},
"buttons": {
"type": "object",
"properties": {
"backgroundColor": {
"type": "string"
},
"color": {
"type": "string"
}
},
"required": [
"backgroundColor",
"color"
],
"additionalProperties": false
},
"inputs": {
"type": "object",
"properties": {
"backgroundColor": {
"type": "string"
},
"color": {
"type": "string"
},
"placeholderColor": {
"type": "string"
}
},
"required": [
"backgroundColor",
"color",
"placeholderColor"
],
"additionalProperties": false
},
"roundness": {
"type": "string",
"enum": [
"none",
"medium",
"large"
]
}
},
"required": [
"hostBubbles",
"guestBubbles",
"buttons",
"inputs"
],
"additionalProperties": false
},
"customCss": {
"type": "string"
}
},
"required": [
"general",
"chat"
],
"additionalProperties": false
}
},
"required": [
"workspaceId",
"name",
"theme"
],
"additionalProperties": false
}
}
}
},
"parameters": [
{
"name": "themeTemplateId",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"themeTemplate": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"theme": {
"type": "object",
"properties": {
"general": {
"type": "object",
"properties": {
"font": {
"type": "string"
},
"background": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": [
"Color",
"Image",
"None"
]
},
"content": {
"type": "string"
}
},
"required": [
"type"
],
"additionalProperties": false
}
},
"required": [
"font",
"background"
],
"additionalProperties": false
},
"chat": {
"type": "object",
"properties": {
"hostAvatar": {
"type": "object",
"properties": {
"isEnabled": {
"type": "boolean"
},
"url": {
"type": "string"
}
},
"required": [
"isEnabled"
],
"additionalProperties": false
},
"guestAvatar": {
"type": "object",
"properties": {
"isEnabled": {
"type": "boolean"
},
"url": {
"type": "string"
}
},
"required": [
"isEnabled"
],
"additionalProperties": false
},
"hostBubbles": {
"type": "object",
"properties": {
"backgroundColor": {
"type": "string"
},
"color": {
"type": "string"
}
},
"required": [
"backgroundColor",
"color"
],
"additionalProperties": false
},
"guestBubbles": {
"type": "object",
"properties": {
"backgroundColor": {
"type": "string"
},
"color": {
"type": "string"
}
},
"required": [
"backgroundColor",
"color"
],
"additionalProperties": false
},
"buttons": {
"type": "object",
"properties": {
"backgroundColor": {
"type": "string"
},
"color": {
"type": "string"
}
},
"required": [
"backgroundColor",
"color"
],
"additionalProperties": false
},
"inputs": {
"type": "object",
"properties": {
"backgroundColor": {
"type": "string"
},
"color": {
"type": "string"
},
"placeholderColor": {
"type": "string"
}
},
"required": [
"backgroundColor",
"color",
"placeholderColor"
],
"additionalProperties": false
},
"roundness": {
"type": "string",
"enum": [
"none",
"medium",
"large"
]
}
},
"required": [
"hostBubbles",
"guestBubbles",
"buttons",
"inputs"
],
"additionalProperties": false
},
"customCss": {
"type": "string"
}
},
"required": [
"general",
"chat"
],
"additionalProperties": false
},
"workspaceId": {
"type": "string"
},
"createdAt": {
"type": "string",
"format": "date-time"
},
"updatedAt": {
"type": "string",
"format": "date-time"
}
},
"required": [
"id",
"name",
"theme",
"workspaceId",
"createdAt",
"updatedAt"
],
"additionalProperties": false
}
},
"required": [
"themeTemplate"
],
"additionalProperties": false
}
}
}
},
"default": {
"$ref": "#/components/responses/error"
}
}
},
"delete": {
"operationId": "mutation.theme.deleteThemeTemplate",
"summary": "Delete a theme template",
"tags": [
"Workspace",
"Theme"
],
"security": [
{
"Authorization": []
}
],
"parameters": [
{
"name": "workspaceId",
"in": "query",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "themeTemplateId",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"themeTemplate": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"theme": {
"type": "object",
"properties": {
"general": {
"type": "object",
"properties": {
"font": {
"type": "string"
},
"background": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": [
"Color",
"Image",
"None"
]
},
"content": {
"type": "string"
}
},
"required": [
"type"
],
"additionalProperties": false
}
},
"required": [
"font",
"background"
],
"additionalProperties": false
},
"chat": {
"type": "object",
"properties": {
"hostAvatar": {
"type": "object",
"properties": {
"isEnabled": {
"type": "boolean"
},
"url": {
"type": "string"
}
},
"required": [
"isEnabled"
],
"additionalProperties": false
},
"guestAvatar": {
"type": "object",
"properties": {
"isEnabled": {
"type": "boolean"
},
"url": {
"type": "string"
}
},
"required": [
"isEnabled"
],
"additionalProperties": false
},
"hostBubbles": {
"type": "object",
"properties": {
"backgroundColor": {
"type": "string"
},
"color": {
"type": "string"
}
},
"required": [
"backgroundColor",
"color"
],
"additionalProperties": false
},
"guestBubbles": {
"type": "object",
"properties": {
"backgroundColor": {
"type": "string"
},
"color": {
"type": "string"
}
},
"required": [
"backgroundColor",
"color"
],
"additionalProperties": false
},
"buttons": {
"type": "object",
"properties": {
"backgroundColor": {
"type": "string"
},
"color": {
"type": "string"
}
},
"required": [
"backgroundColor",
"color"
],
"additionalProperties": false
},
"inputs": {
"type": "object",
"properties": {
"backgroundColor": {
"type": "string"
},
"color": {
"type": "string"
},
"placeholderColor": {
"type": "string"
}
},
"required": [
"backgroundColor",
"color",
"placeholderColor"
],
"additionalProperties": false
},
"roundness": {
"type": "string",
"enum": [
"none",
"medium",
"large"
]
}
},
"required": [
"hostBubbles",
"guestBubbles",
"buttons",
"inputs"
],
"additionalProperties": false
},
"customCss": {
"type": "string"
}
},
"required": [
"general",
"chat"
],
"additionalProperties": false
},
"workspaceId": {
"type": "string"
},
"createdAt": {
"type": "string",
"format": "date-time"
},
"updatedAt": {
"type": "string",
"format": "date-time"
}
},
"required": [
"id",
"name",
"theme",
"workspaceId",
"createdAt",
"updatedAt"
],
"additionalProperties": false
}
},
"required": [
"themeTemplate"
],
"additionalProperties": false
}
}
}
},
"default": {
"$ref": "#/components/responses/error"
}
}
}
}
},
"components": {

View File

@@ -2765,6 +2765,14 @@
"placeholderColor"
],
"additionalProperties": false
},
"roundness": {
"type": "string",
"enum": [
"none",
"medium",
"large"
]
}
},
"required": [
@@ -4145,6 +4153,14 @@
"placeholderColor"
],
"additionalProperties": false
},
"roundness": {
"type": "string",
"enum": [
"none",
"medium",
"large"
]
}
},
"required": [

View File

@@ -1,6 +1,6 @@
{
"name": "@typebot.io/js",
"version": "0.0.30",
"version": "0.0.31",
"description": "Javascript library to display typebots on your website",
"type": "module",
"main": "dist/index.js",

View File

@@ -159,21 +159,31 @@ const BotContent = (props: BotContentProps) => {
})
const injectCustomFont = () => {
const existingFont = document.getElementById('bot-font')
if (
existingFont
?.getAttribute('href')
?.includes(
props.initialChatReply.typebot?.theme?.general?.font ?? 'Open Sans'
)
)
return
const font = document.createElement('link')
font.href = `https://fonts.googleapis.com/css2?family=${
props.initialChatReply.typebot?.theme?.general?.font ?? 'Open Sans'
}:ital,wght@0,300;0,400;0,600;1,300;1,400;1,600&display=swap');')`
font.rel = 'stylesheet'
font.id = 'bot-font'
document.head.appendChild(font)
}
onMount(() => {
injectCustomFont()
if (!botContainer) return
resizeObserver.observe(botContainer)
})
createEffect(() => {
injectCustomFont()
if (!botContainer) return
setCssVariablesValue(props.initialChatReply.typebot.theme, botContainer)
})
@@ -187,7 +197,7 @@ const BotContent = (props: BotContentProps) => {
<div
ref={botContainer}
class={
'relative flex w-full h-full text-base overflow-hidden bg-cover flex-col items-center typebot-container ' +
'relative flex w-full h-full text-base overflow-hidden bg-cover bg-center flex-col items-center typebot-container ' +
props.class
}
>

View File

@@ -62,8 +62,8 @@ export const ChoiceForm = (props: Props) => {
</button>
{props.inputIndex === 0 && props.block.items.length === 1 && (
<span class="flex h-3 w-3 absolute top-0 right-0 -mt-1 -mr-1 ping">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full brightness-225 opacity-75" />
<span class="relative inline-flex rounded-full h-3 w-3 brightness-200" />
<span class="animate-ping absolute inline-flex h-full w-full rounded-full brightness-200 opacity-75" />
<span class="relative inline-flex rounded-full h-3 w-3 brightness-150" />
</span>
)}
</span>

View File

@@ -134,6 +134,8 @@ const setTypebotBackground = (
background: Background,
documentStyle: CSSStyleDeclaration
) => {
documentStyle.setProperty(cssVariableNames.general.bgImage, null)
documentStyle.setProperty(cssVariableNames.general.bgColor, null)
documentStyle.setProperty(
background?.type === BackgroundType.IMAGE
? cssVariableNames.general.bgImage

View File

@@ -1,6 +1,6 @@
{
"name": "@typebot.io/react",
"version": "0.0.30",
"version": "0.0.31",
"description": "React library to display typebots on your website",
"main": "dist/index.js",
"types": "dist/index.d.ts",

View File

@@ -27,6 +27,7 @@ export const parseTestTypebot = (
createdAt: new Date(),
customDomain: null,
icon: null,
selectedThemeTemplateId: null,
isArchived: false,
isClosed: false,
resultsTablePreferences: null,

View File

@@ -95,6 +95,7 @@ model Workspace {
customChatsLimit Int?
customStorageLimit Int?
customSeatsLimit Int?
themeTemplates ThemeTemplate[]
}
model MemberInWorkspace {
@@ -180,6 +181,7 @@ model Typebot {
variables Json
edges Json
theme Json
selectedThemeTemplateId String?
settings Json
publicId String? @unique
customDomain String? @unique
@@ -325,6 +327,18 @@ model ChatSession {
state Json
}
model ThemeTemplate {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
name String
theme Json
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
workspaceId String
@@index([workspaceId])
}
enum WorkspaceRole {
ADMIN
MEMBER

View File

@@ -0,0 +1,17 @@
-- AlterTable
ALTER TABLE "Typebot" ADD COLUMN "selectedThemeTemplateId" TEXT;
-- CreateTable
CREATE TABLE "ThemeTemplate" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"name" TEXT NOT NULL,
"theme" JSONB NOT NULL,
"workspaceId" TEXT NOT NULL,
CONSTRAINT "ThemeTemplate_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "ThemeTemplate" ADD CONSTRAINT "ThemeTemplate_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "Workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -89,6 +89,7 @@ model Workspace {
customChatsLimit Int?
customStorageLimit Int?
customSeatsLimit Int?
themeTemplates ThemeTemplate[]
}
model MemberInWorkspace {
@@ -164,6 +165,7 @@ model Typebot {
variables Json
edges Json
theme Json
selectedThemeTemplateId String?
settings Json
publicId String? @unique
customDomain String? @unique
@@ -304,6 +306,16 @@ model ChatSession {
state Json
}
model ThemeTemplate {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
name String
theme Json
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
workspaceId String
}
enum WorkspaceRole {
ADMIN
MEMBER

View File

@@ -1,3 +1,4 @@
import { ThemeTemplate as ThemeTemplatePrisma } from '@typebot.io/prisma'
import { z } from 'zod'
import { BackgroundType } from './enums'
@@ -43,6 +44,15 @@ export const themeSchema = z.object({
customCss: z.string().optional(),
})
export const themeTemplateSchema = z.object({
id: z.string(),
name: z.string(),
theme: themeSchema,
workspaceId: z.string(),
createdAt: z.date(),
updatedAt: z.date(),
}) satisfies z.ZodType<ThemeTemplatePrisma>
export const defaultTheme: Theme = {
chat: {
hostBubbles: { backgroundColor: '#F7F8FF', color: '#303235' },
@@ -67,3 +77,4 @@ export type GeneralTheme = z.infer<typeof generalThemeSchema>
export type Background = z.infer<typeof backgroundSchema>
export type ContainerColors = z.infer<typeof containerColorsSchema>
export type InputColors = z.infer<typeof inputColorsSchema>
export type ThemeTemplate = z.infer<typeof themeTemplateSchema>

View File

@@ -46,6 +46,7 @@ export const typebotSchema = z.object({
edges: z.array(edgeSchema),
variables: z.array(variableSchema),
theme: themeSchema,
selectedThemeTemplateId: z.string().nullable(),
settings: settingsSchema,
createdAt: z.date(),
updatedAt: z.date(),

16
pnpm-lock.yaml generated
View File

@@ -660,10 +660,10 @@ importers:
packages/embeds/wordpress:
specifiers:
'@prettier/plugin-php': 0.19.4
prettier: 2.8.4
prettier: 2.8.7
devDependencies:
'@prettier/plugin-php': 0.19.4_prettier@2.8.4
prettier: 2.8.4
'@prettier/plugin-php': 0.19.4_prettier@2.8.7
prettier: 2.8.7
packages/eslint-config-custom:
specifiers:
@@ -5641,7 +5641,7 @@ packages:
resolution: {integrity: sha512-50/17A98tWUfQ176raKiOGXuYpLyyVMkxxG6oylzL3BPOlA6ADGdK7EYunSa4I064xerltq9TGXs8HmOk5E+vw==}
dev: false
/@prettier/plugin-php/0.19.4_prettier@2.8.4:
/@prettier/plugin-php/0.19.4_prettier@2.8.7:
resolution: {integrity: sha512-FiSnSfP+Vo0/HVRXg7ZnEYJEM1eWS+MmsozYtzEdIf8Vg9v/+fwvyXMNayI5SgZ1Y9F5LGhl/EOMWIPzr9c2Xg==}
peerDependencies:
prettier: ^1.15.0 || ^2.0.0
@@ -5649,7 +5649,7 @@ packages:
linguist-languages: 7.21.0
mem: 8.1.1
php-parser: 3.1.3
prettier: 2.8.4
prettier: 2.8.7
dev: true
/@prisma/client/4.11.0_prisma@4.11.0:
@@ -16728,6 +16728,12 @@ packages:
engines: {node: '>=10.13.0'}
hasBin: true
/prettier/2.8.7:
resolution: {integrity: sha512-yPngTo3aXUUmyuTjeTUT75txrf+aMh9FiD7q9ZE/i6r0bPb22g4FsE6Y338PQX1bmfy08i9QQCB7/rcUAVntfw==}
engines: {node: '>=10.13.0'}
hasBin: true
dev: true
/pretty-error/4.0.0:
resolution: {integrity: sha512-AoJ5YMAcXKYxKhuJGdcvse+Voc6v1RgnsR3nWcYU7q4t6z0Q6T86sv5Zq8VIRbOWWFpvdGE83LtdSMNd+6Y0xw==}
dependencies: