♻️ (builder) Change to features-centric folder structure
This commit is contained in:
committed by
Baptiste Arnaud
parent
3686465a85
commit
643571fe7d
@ -0,0 +1,87 @@
|
||||
import React from 'react'
|
||||
import { AvatarProps } from 'models'
|
||||
import {
|
||||
Heading,
|
||||
HStack,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Stack,
|
||||
Switch,
|
||||
Image,
|
||||
Flex,
|
||||
Box,
|
||||
Portal,
|
||||
} from '@chakra-ui/react'
|
||||
import { ImageUploadContent } from '@/components/ImageUploadContent'
|
||||
import { DefaultAvatar } from '../DefaultAvatar'
|
||||
|
||||
type Props = {
|
||||
uploadFilePath: string
|
||||
title: string
|
||||
avatarProps?: AvatarProps
|
||||
isDefaultCheck?: boolean
|
||||
onAvatarChange: (avatarProps: AvatarProps) => void
|
||||
}
|
||||
|
||||
export const AvatarForm = ({
|
||||
uploadFilePath,
|
||||
title,
|
||||
avatarProps,
|
||||
isDefaultCheck = false,
|
||||
onAvatarChange,
|
||||
}: Props) => {
|
||||
const isChecked = avatarProps ? avatarProps.isEnabled : isDefaultCheck
|
||||
const handleOnCheck = () =>
|
||||
onAvatarChange({ ...avatarProps, isEnabled: !isChecked })
|
||||
const handleImageUrl = (url: string) =>
|
||||
onAvatarChange({ isEnabled: isChecked, url })
|
||||
|
||||
const isDefaultAvatar = !avatarProps?.url || avatarProps.url.includes('{{')
|
||||
return (
|
||||
<Stack borderWidth={1} rounded="md" p="4" spacing={4}>
|
||||
<Flex justifyContent="space-between">
|
||||
<HStack>
|
||||
<Heading as="label" fontSize="lg" htmlFor={title} mb="1">
|
||||
{title}
|
||||
</Heading>
|
||||
<Switch isChecked={isChecked} id={title} onChange={handleOnCheck} />
|
||||
</HStack>
|
||||
{isChecked && (
|
||||
<Popover isLazy>
|
||||
<PopoverTrigger>
|
||||
{isDefaultAvatar ? (
|
||||
<Box>
|
||||
<DefaultAvatar
|
||||
cursor="pointer"
|
||||
_hover={{ filter: 'brightness(.9)' }}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<Image
|
||||
src={avatarProps.url}
|
||||
alt="Website image"
|
||||
cursor="pointer"
|
||||
_hover={{ filter: 'brightness(.9)' }}
|
||||
transition="filter 200ms"
|
||||
rounded="full"
|
||||
boxSize="40px"
|
||||
objectFit="cover"
|
||||
/>
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
<Portal>
|
||||
<PopoverContent p="4">
|
||||
<ImageUploadContent
|
||||
filePath={uploadFilePath}
|
||||
defaultUrl={avatarProps?.url}
|
||||
onSubmit={handleImageUrl}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Portal>
|
||||
</Popover>
|
||||
)}
|
||||
</Flex>
|
||||
</Stack>
|
||||
)
|
||||
}
|
@ -0,0 +1,35 @@
|
||||
import { Stack, Flex, Text } from '@chakra-ui/react'
|
||||
import { ContainerColors } from 'models'
|
||||
import React from 'react'
|
||||
import { ColorPicker } from '../../../../components/ColorPicker'
|
||||
|
||||
type Props = {
|
||||
buttons: ContainerColors
|
||||
onButtonsChange: (buttons: ContainerColors) => void
|
||||
}
|
||||
|
||||
export const ButtonsTheme = ({ buttons, onButtonsChange }: Props) => {
|
||||
const handleBackgroundChange = (backgroundColor: string) =>
|
||||
onButtonsChange({ ...buttons, backgroundColor })
|
||||
const handleTextChange = (color: string) =>
|
||||
onButtonsChange({ ...buttons, color })
|
||||
|
||||
return (
|
||||
<Stack data-testid="buttons-theme">
|
||||
<Flex justify="space-between" align="center">
|
||||
<Text>Background:</Text>
|
||||
<ColorPicker
|
||||
initialColor={buttons.backgroundColor}
|
||||
onColorChange={handleBackgroundChange}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Text>Text:</Text>
|
||||
<ColorPicker
|
||||
initialColor={buttons.color}
|
||||
onColorChange={handleTextChange}
|
||||
/>
|
||||
</Flex>
|
||||
</Stack>
|
||||
)
|
||||
}
|
@ -0,0 +1,80 @@
|
||||
import { Heading, Stack } from '@chakra-ui/react'
|
||||
import { AvatarProps, ChatTheme, ContainerColors, InputColors } from 'models'
|
||||
import React from 'react'
|
||||
import { AvatarForm } from './AvatarForm'
|
||||
import { ButtonsTheme } from './ButtonsTheme'
|
||||
import { GuestBubbles } from './GuestBubbles'
|
||||
import { HostBubbles } from './HostBubbles'
|
||||
import { InputsTheme } from './InputsTheme'
|
||||
|
||||
type Props = {
|
||||
typebotId: string
|
||||
chatTheme: ChatTheme
|
||||
onChatThemeChange: (chatTheme: ChatTheme) => void
|
||||
}
|
||||
|
||||
export const ChatThemeSettings = ({
|
||||
typebotId,
|
||||
chatTheme,
|
||||
onChatThemeChange,
|
||||
}: Props) => {
|
||||
const handleHostBubblesChange = (hostBubbles: ContainerColors) =>
|
||||
onChatThemeChange({ ...chatTheme, hostBubbles })
|
||||
const handleGuestBubblesChange = (guestBubbles: ContainerColors) =>
|
||||
onChatThemeChange({ ...chatTheme, guestBubbles })
|
||||
const handleButtonsChange = (buttons: ContainerColors) =>
|
||||
onChatThemeChange({ ...chatTheme, buttons })
|
||||
const handleInputsChange = (inputs: InputColors) =>
|
||||
onChatThemeChange({ ...chatTheme, inputs })
|
||||
|
||||
const handleHostAvatarChange = (hostAvatar: AvatarProps) =>
|
||||
onChatThemeChange({ ...chatTheme, hostAvatar })
|
||||
const handleGuestAvatarChange = (guestAvatar: AvatarProps) =>
|
||||
onChatThemeChange({ ...chatTheme, guestAvatar })
|
||||
|
||||
return (
|
||||
<Stack spacing={6}>
|
||||
<AvatarForm
|
||||
uploadFilePath={`typebots/${typebotId}/hostAvatar`}
|
||||
title="Bot avatar"
|
||||
avatarProps={chatTheme.hostAvatar}
|
||||
isDefaultCheck
|
||||
onAvatarChange={handleHostAvatarChange}
|
||||
/>
|
||||
<AvatarForm
|
||||
uploadFilePath={`typebots/${typebotId}/guestAvatar`}
|
||||
title="User avatar"
|
||||
avatarProps={chatTheme.guestAvatar}
|
||||
onAvatarChange={handleGuestAvatarChange}
|
||||
/>
|
||||
<Stack borderWidth={1} rounded="md" p="4" spacing={4}>
|
||||
<Heading fontSize="lg">Bot bubbles</Heading>
|
||||
<HostBubbles
|
||||
hostBubbles={chatTheme.hostBubbles}
|
||||
onHostBubblesChange={handleHostBubblesChange}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack borderWidth={1} rounded="md" p="4" spacing={4}>
|
||||
<Heading fontSize="lg">User bubbles</Heading>
|
||||
<GuestBubbles
|
||||
guestBubbles={chatTheme.guestBubbles}
|
||||
onGuestBubblesChange={handleGuestBubblesChange}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack borderWidth={1} rounded="md" p="4" spacing={4}>
|
||||
<Heading fontSize="lg">Buttons</Heading>
|
||||
<ButtonsTheme
|
||||
buttons={chatTheme.buttons}
|
||||
onButtonsChange={handleButtonsChange}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack borderWidth={1} rounded="md" p="4" spacing={4}>
|
||||
<Heading fontSize="lg">Inputs</Heading>
|
||||
<InputsTheme
|
||||
inputs={chatTheme.inputs}
|
||||
onInputsChange={handleInputsChange}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
)
|
||||
}
|
@ -0,0 +1,35 @@
|
||||
import { Stack, Flex, Text } from '@chakra-ui/react'
|
||||
import { ContainerColors } from 'models'
|
||||
import React from 'react'
|
||||
import { ColorPicker } from '../../../../components/ColorPicker'
|
||||
|
||||
type Props = {
|
||||
guestBubbles: ContainerColors
|
||||
onGuestBubblesChange: (hostBubbles: ContainerColors) => void
|
||||
}
|
||||
|
||||
export const GuestBubbles = ({ guestBubbles, onGuestBubblesChange }: Props) => {
|
||||
const handleBackgroundChange = (backgroundColor: string) =>
|
||||
onGuestBubblesChange({ ...guestBubbles, backgroundColor })
|
||||
const handleTextChange = (color: string) =>
|
||||
onGuestBubblesChange({ ...guestBubbles, color })
|
||||
|
||||
return (
|
||||
<Stack data-testid="guest-bubbles-theme">
|
||||
<Flex justify="space-between" align="center">
|
||||
<Text>Background:</Text>
|
||||
<ColorPicker
|
||||
initialColor={guestBubbles.backgroundColor}
|
||||
onColorChange={handleBackgroundChange}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Text>Text:</Text>
|
||||
<ColorPicker
|
||||
initialColor={guestBubbles.color}
|
||||
onColorChange={handleTextChange}
|
||||
/>
|
||||
</Flex>
|
||||
</Stack>
|
||||
)
|
||||
}
|
@ -0,0 +1,35 @@
|
||||
import { Stack, Flex, Text } from '@chakra-ui/react'
|
||||
import { ContainerColors } from 'models'
|
||||
import React from 'react'
|
||||
import { ColorPicker } from '../../../../components/ColorPicker'
|
||||
|
||||
type Props = {
|
||||
hostBubbles: ContainerColors
|
||||
onHostBubblesChange: (hostBubbles: ContainerColors) => void
|
||||
}
|
||||
|
||||
export const HostBubbles = ({ hostBubbles, onHostBubblesChange }: Props) => {
|
||||
const handleBackgroundChange = (backgroundColor: string) =>
|
||||
onHostBubblesChange({ ...hostBubbles, backgroundColor })
|
||||
const handleTextChange = (color: string) =>
|
||||
onHostBubblesChange({ ...hostBubbles, color })
|
||||
|
||||
return (
|
||||
<Stack data-testid="host-bubbles-theme">
|
||||
<Flex justify="space-between" align="center">
|
||||
<Text>Background:</Text>
|
||||
<ColorPicker
|
||||
initialColor={hostBubbles.backgroundColor}
|
||||
onColorChange={handleBackgroundChange}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Text>Text:</Text>
|
||||
<ColorPicker
|
||||
initialColor={hostBubbles.color}
|
||||
onColorChange={handleTextChange}
|
||||
/>
|
||||
</Flex>
|
||||
</Stack>
|
||||
)
|
||||
}
|
@ -0,0 +1,44 @@
|
||||
import { Stack, Flex, Text } from '@chakra-ui/react'
|
||||
import { InputColors } from 'models'
|
||||
import React from 'react'
|
||||
import { ColorPicker } from '../../../../components/ColorPicker'
|
||||
|
||||
type Props = {
|
||||
inputs: InputColors
|
||||
onInputsChange: (buttons: InputColors) => void
|
||||
}
|
||||
|
||||
export const InputsTheme = ({ inputs, onInputsChange }: Props) => {
|
||||
const handleBackgroundChange = (backgroundColor: string) =>
|
||||
onInputsChange({ ...inputs, backgroundColor })
|
||||
const handleTextChange = (color: string) =>
|
||||
onInputsChange({ ...inputs, color })
|
||||
const handlePlaceholderChange = (placeholderColor: string) =>
|
||||
onInputsChange({ ...inputs, placeholderColor })
|
||||
|
||||
return (
|
||||
<Stack data-testid="inputs-theme">
|
||||
<Flex justify="space-between" align="center">
|
||||
<Text>Background:</Text>
|
||||
<ColorPicker
|
||||
initialColor={inputs.backgroundColor}
|
||||
onColorChange={handleBackgroundChange}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Text>Text:</Text>
|
||||
<ColorPicker
|
||||
initialColor={inputs.color}
|
||||
onColorChange={handleTextChange}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Text>Placeholder text:</Text>
|
||||
<ColorPicker
|
||||
initialColor={inputs.placeholderColor}
|
||||
onColorChange={handlePlaceholderChange}
|
||||
/>
|
||||
</Flex>
|
||||
</Stack>
|
||||
)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { ChatThemeSettings } from './ChatThemeSettings'
|
@ -0,0 +1,17 @@
|
||||
import { CodeEditor } from '@/components/CodeEditor'
|
||||
import React from 'react'
|
||||
|
||||
type Props = {
|
||||
customCss?: string
|
||||
onCustomCssChange: (css: string) => void
|
||||
}
|
||||
|
||||
export const CustomCssSettings = ({ customCss, onCustomCssChange }: Props) => {
|
||||
return (
|
||||
<CodeEditor
|
||||
value={customCss ?? ''}
|
||||
lang="css"
|
||||
onChange={onCustomCssChange}
|
||||
/>
|
||||
)
|
||||
}
|
38
apps/builder/src/features/theme/components/DefaultAvatar.tsx
Normal file
38
apps/builder/src/features/theme/components/DefaultAvatar.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import { Icon, IconProps } from '@chakra-ui/react'
|
||||
import React from 'react'
|
||||
|
||||
export const DefaultAvatar = (props: IconProps) => {
|
||||
return (
|
||||
<Icon
|
||||
viewBox="0 0 75 75"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
boxSize="40px"
|
||||
data-testid="default-avatar"
|
||||
{...props}
|
||||
>
|
||||
<mask id="mask0" x="0" y="0" mask-type="alpha">
|
||||
<circle cx="37.5" cy="37.5" r="37.5" fill="#0042DA" />
|
||||
</mask>
|
||||
<g mask="url(#mask0)">
|
||||
<rect x="-30" y="-43" width="131" height="154" fill="#0042DA" />
|
||||
<rect
|
||||
x="2.50413"
|
||||
y="120.333"
|
||||
width="81.5597"
|
||||
height="86.4577"
|
||||
rx="2.5"
|
||||
transform="rotate(-52.6423 2.50413 120.333)"
|
||||
stroke="#FED23D"
|
||||
strokeWidth="5"
|
||||
/>
|
||||
<circle cx="76.5" cy="-1.5" r="29" stroke="#FF8E20" strokeWidth="5" />
|
||||
<path
|
||||
d="M-49.8224 22L-15.5 -40.7879L18.8224 22H-49.8224Z"
|
||||
stroke="#F7F8FF"
|
||||
strokeWidth="5"
|
||||
/>
|
||||
</g>
|
||||
</Icon>
|
||||
)
|
||||
}
|
@ -0,0 +1,40 @@
|
||||
import { Flex, Text } from '@chakra-ui/react'
|
||||
import { Background, BackgroundType } from 'models'
|
||||
import React from 'react'
|
||||
import { ColorPicker } from '../../../../../components/ColorPicker'
|
||||
|
||||
type BackgroundContentProps = {
|
||||
background?: Background
|
||||
onBackgroundContentChange: (content: string) => void
|
||||
}
|
||||
|
||||
const defaultBackgroundColor = '#ffffff'
|
||||
|
||||
export const BackgroundContent = ({
|
||||
background,
|
||||
onBackgroundContentChange,
|
||||
}: BackgroundContentProps) => {
|
||||
const handleContentChange = (content: string) =>
|
||||
onBackgroundContentChange(content)
|
||||
|
||||
switch (background?.type) {
|
||||
case BackgroundType.COLOR:
|
||||
return (
|
||||
<Flex justify="space-between" align="center">
|
||||
<Text>Background color:</Text>
|
||||
<ColorPicker
|
||||
initialColor={background.content ?? defaultBackgroundColor}
|
||||
onColorChange={handleContentChange}
|
||||
/>
|
||||
</Flex>
|
||||
)
|
||||
case BackgroundType.IMAGE:
|
||||
return (
|
||||
<Flex>
|
||||
<Text>Image</Text>
|
||||
</Flex>
|
||||
)
|
||||
default:
|
||||
return <></>
|
||||
}
|
||||
}
|
@ -0,0 +1,37 @@
|
||||
import { Stack, Text } from '@chakra-ui/react'
|
||||
import { Background, BackgroundType } from 'models'
|
||||
import React from 'react'
|
||||
import { BackgroundContent } from './BackgroundContent'
|
||||
import { BackgroundTypeRadioButtons } from './BackgroundTypeRadioButtons'
|
||||
|
||||
type Props = {
|
||||
background?: Background
|
||||
onBackgroundChange: (newBackground: Background) => void
|
||||
}
|
||||
|
||||
const defaultBackgroundType = BackgroundType.NONE
|
||||
|
||||
export const BackgroundSelector = ({
|
||||
background,
|
||||
onBackgroundChange,
|
||||
}: Props) => {
|
||||
const handleBackgroundTypeChange = (type: BackgroundType) =>
|
||||
background && onBackgroundChange({ ...background, type })
|
||||
|
||||
const handleBackgroundContentChange = (content: string) =>
|
||||
background && onBackgroundChange({ ...background, content })
|
||||
|
||||
return (
|
||||
<Stack spacing={4}>
|
||||
<Text>Background</Text>
|
||||
<BackgroundTypeRadioButtons
|
||||
backgroundType={background?.type ?? defaultBackgroundType}
|
||||
onBackgroundTypeChange={handleBackgroundTypeChange}
|
||||
/>
|
||||
<BackgroundContent
|
||||
background={background}
|
||||
onBackgroundContentChange={handleBackgroundContentChange}
|
||||
/>
|
||||
</Stack>
|
||||
)
|
||||
}
|
@ -0,0 +1,73 @@
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
HStack,
|
||||
useRadio,
|
||||
useRadioGroup,
|
||||
UseRadioProps,
|
||||
} from '@chakra-ui/react'
|
||||
import { BackgroundType } from 'models'
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
type Props = {
|
||||
backgroundType: BackgroundType
|
||||
onBackgroundTypeChange: (type: BackgroundType) => void
|
||||
}
|
||||
export const BackgroundTypeRadioButtons = ({
|
||||
backgroundType,
|
||||
onBackgroundTypeChange,
|
||||
}: Props) => {
|
||||
const options = ['Color', 'None']
|
||||
|
||||
const { getRootProps, getRadioProps } = useRadioGroup({
|
||||
name: 'background-type',
|
||||
defaultValue: backgroundType,
|
||||
onChange: (nextVal: string) =>
|
||||
onBackgroundTypeChange(nextVal as BackgroundType),
|
||||
})
|
||||
|
||||
const group = getRootProps()
|
||||
|
||||
return (
|
||||
<HStack {...group}>
|
||||
{options.map((value) => {
|
||||
const radio = getRadioProps({ value })
|
||||
return (
|
||||
<RadioCard key={value} {...radio}>
|
||||
{value}
|
||||
</RadioCard>
|
||||
)
|
||||
})}
|
||||
</HStack>
|
||||
)
|
||||
}
|
||||
|
||||
export const RadioCard = (props: UseRadioProps & { children: ReactNode }) => {
|
||||
const { getInputProps, getCheckboxProps } = useRadio(props)
|
||||
|
||||
const input = getInputProps()
|
||||
const checkbox = getCheckboxProps()
|
||||
|
||||
return (
|
||||
<Box as="label" flex="1">
|
||||
<input {...input} />
|
||||
<Flex
|
||||
{...checkbox}
|
||||
cursor="pointer"
|
||||
borderWidth="1px"
|
||||
borderRadius="md"
|
||||
_checked={{
|
||||
bg: 'orange.400',
|
||||
color: 'white',
|
||||
borderColor: 'orange.400',
|
||||
}}
|
||||
px={5}
|
||||
py={2}
|
||||
transition="background-color 150ms, color 150ms, border 150ms"
|
||||
justifyContent="center"
|
||||
>
|
||||
{props.children}
|
||||
</Flex>
|
||||
</Box>
|
||||
)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { BackgroundSelector } from './BackgroundSelector'
|
@ -0,0 +1,50 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Text, HStack } from '@chakra-ui/react'
|
||||
import { SearchableDropdown } from '@/components/SearchableDropdown'
|
||||
import { env, isEmpty } from 'utils'
|
||||
|
||||
type FontSelectorProps = {
|
||||
activeFont?: string
|
||||
onSelectFont: (font: string) => void
|
||||
}
|
||||
|
||||
export const FontSelector = ({
|
||||
activeFont,
|
||||
onSelectFont,
|
||||
}: FontSelectorProps) => {
|
||||
const [currentFont, setCurrentFont] = useState(activeFont)
|
||||
const [googleFonts, setGoogleFonts] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
fetchPopularFonts().then(setGoogleFonts)
|
||||
}, [])
|
||||
|
||||
const fetchPopularFonts = async () => {
|
||||
if (isEmpty(env('GOOGLE_API_KEY'))) return []
|
||||
const response = await fetch(
|
||||
`https://www.googleapis.com/webfonts/v1/webfonts?key=${env(
|
||||
'GOOGLE_API_KEY'
|
||||
)}&sort=popularity`
|
||||
)
|
||||
return (await response.json()).items.map(
|
||||
(item: { family: string }) => item.family
|
||||
)
|
||||
}
|
||||
|
||||
const handleFontSelected = (nextFont: string) => {
|
||||
if (nextFont == currentFont) return
|
||||
setCurrentFont(nextFont)
|
||||
onSelectFont(nextFont)
|
||||
}
|
||||
|
||||
return (
|
||||
<HStack justify="space-between" align="center">
|
||||
<Text>Font</Text>
|
||||
<SearchableDropdown
|
||||
selectedItem={activeFont}
|
||||
items={googleFonts}
|
||||
onValueChange={handleFontSelected}
|
||||
/>
|
||||
</HStack>
|
||||
)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { FontSelector } from './FontSelector'
|
@ -0,0 +1,34 @@
|
||||
import { Stack } from '@chakra-ui/react'
|
||||
import { Background, GeneralTheme } from 'models'
|
||||
import React from 'react'
|
||||
import { BackgroundSelector } from './BackgroundSelector'
|
||||
import { FontSelector } from './FontSelector'
|
||||
|
||||
type Props = {
|
||||
generalTheme: GeneralTheme
|
||||
onGeneralThemeChange: (general: GeneralTheme) => void
|
||||
}
|
||||
|
||||
export const GeneralSettings = ({
|
||||
generalTheme,
|
||||
onGeneralThemeChange,
|
||||
}: Props) => {
|
||||
const handleSelectFont = (font: string) =>
|
||||
onGeneralThemeChange({ ...generalTheme, font })
|
||||
|
||||
const handleBackgroundChange = (background: Background) =>
|
||||
onGeneralThemeChange({ ...generalTheme, background })
|
||||
|
||||
return (
|
||||
<Stack spacing={6}>
|
||||
<FontSelector
|
||||
activeFont={generalTheme.font}
|
||||
onSelectFont={handleSelectFont}
|
||||
/>
|
||||
<BackgroundSelector
|
||||
background={generalTheme.background}
|
||||
onBackgroundChange={handleBackgroundChange}
|
||||
/>
|
||||
</Stack>
|
||||
)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { GeneralSettings } from './GeneralSettings'
|
30
apps/builder/src/features/theme/components/ThemePage.tsx
Normal file
30
apps/builder/src/features/theme/components/ThemePage.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import { Seo } from '@/components/Seo'
|
||||
import { TypebotHeader, useTypebot } from '@/features/editor'
|
||||
import { Flex } from '@chakra-ui/react'
|
||||
import { TypebotViewer } from 'bot-engine'
|
||||
import { getViewerUrl } from 'utils'
|
||||
import { ThemeSideMenu } from './ThemeSideMenu'
|
||||
import { parseTypebotToPublicTypebot } from '@/features/publish'
|
||||
|
||||
export const ThemePage = () => {
|
||||
const { typebot } = useTypebot()
|
||||
const publicTypebot = typebot && parseTypebotToPublicTypebot(typebot)
|
||||
|
||||
return (
|
||||
<Flex overflow="hidden" h="100vh" flexDir="column">
|
||||
<Seo title="Theme" />
|
||||
<TypebotHeader />
|
||||
<Flex h="full" w="full">
|
||||
<ThemeSideMenu />
|
||||
<Flex flex="1">
|
||||
{publicTypebot && (
|
||||
<TypebotViewer
|
||||
apiHost={getViewerUrl({ isBuilder: true })}
|
||||
typebot={publicTypebot}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
)
|
||||
}
|
101
apps/builder/src/features/theme/components/ThemeSideMenu.tsx
Normal file
101
apps/builder/src/features/theme/components/ThemeSideMenu.tsx
Normal file
@ -0,0 +1,101 @@
|
||||
import {
|
||||
Accordion,
|
||||
AccordionButton,
|
||||
AccordionIcon,
|
||||
AccordionItem,
|
||||
AccordionPanel,
|
||||
Heading,
|
||||
HStack,
|
||||
Stack,
|
||||
} from '@chakra-ui/react'
|
||||
import { ChatIcon, CodeIcon, PencilIcon } from '@/components/icons'
|
||||
import { ChatTheme, GeneralTheme } from 'models'
|
||||
import React from 'react'
|
||||
import { ChatThemeSettings } from './ChatSettings'
|
||||
import { CustomCssSettings } from './CustomCssSettings/CustomCssSettings'
|
||||
import { GeneralSettings } from './GeneralSettings'
|
||||
import { headerHeight, useTypebot } from '@/features/editor'
|
||||
|
||||
export const ThemeSideMenu = () => {
|
||||
const { typebot, updateTypebot } = useTypebot()
|
||||
|
||||
const handleChatThemeChange = (chat: ChatTheme) =>
|
||||
typebot && updateTypebot({ theme: { ...typebot.theme, chat } })
|
||||
|
||||
const handleGeneralThemeChange = (general: GeneralTheme) =>
|
||||
typebot && updateTypebot({ theme: { ...typebot.theme, general } })
|
||||
|
||||
const handleCustomCssChange = (customCss: string) =>
|
||||
typebot && updateTypebot({ theme: { ...typebot.theme, customCss } })
|
||||
|
||||
return (
|
||||
<Stack
|
||||
flex="1"
|
||||
maxW="400px"
|
||||
height={`calc(100vh - ${headerHeight}px)`}
|
||||
borderRightWidth={1}
|
||||
pt={10}
|
||||
spacing={10}
|
||||
overflowY="scroll"
|
||||
pb="20"
|
||||
>
|
||||
<Heading fontSize="xl" textAlign="center">
|
||||
Customize the theme
|
||||
</Heading>
|
||||
<Accordion allowMultiple defaultIndex={[0]}>
|
||||
<AccordionItem>
|
||||
<AccordionButton py={6}>
|
||||
<HStack flex="1" pl={2}>
|
||||
<PencilIcon />
|
||||
<Heading fontSize="lg">General</Heading>
|
||||
</HStack>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
<AccordionPanel pb={4}>
|
||||
{typebot && (
|
||||
<GeneralSettings
|
||||
generalTheme={typebot.theme.general}
|
||||
onGeneralThemeChange={handleGeneralThemeChange}
|
||||
/>
|
||||
)}
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
<AccordionItem>
|
||||
<AccordionButton py={6}>
|
||||
<HStack flex="1" pl={2}>
|
||||
<ChatIcon />
|
||||
<Heading fontSize="lg">Chat</Heading>
|
||||
</HStack>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
<AccordionPanel pb={4}>
|
||||
{typebot && (
|
||||
<ChatThemeSettings
|
||||
typebotId={typebot.id}
|
||||
chatTheme={typebot.theme.chat}
|
||||
onChatThemeChange={handleChatThemeChange}
|
||||
/>
|
||||
)}
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
<AccordionItem>
|
||||
<AccordionButton py={6}>
|
||||
<HStack flex="1" pl={2}>
|
||||
<CodeIcon />
|
||||
<Heading fontSize="lg">Custom CSS</Heading>
|
||||
</HStack>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
<AccordionPanel pb={4}>
|
||||
{typebot && (
|
||||
<CustomCssSettings
|
||||
customCss={typebot.theme.customCss}
|
||||
onCustomCssChange={handleCustomCssChange}
|
||||
/>
|
||||
)}
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</Stack>
|
||||
)
|
||||
}
|
1
apps/builder/src/features/theme/index.ts
Normal file
1
apps/builder/src/features/theme/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { ThemePage } from './components/ThemePage'
|
184
apps/builder/src/features/theme/theme.spec.ts
Normal file
184
apps/builder/src/features/theme/theme.spec.ts
Normal file
@ -0,0 +1,184 @@
|
||||
import { getTestAsset } from '@/test/utils/playwright'
|
||||
import test, { expect } from '@playwright/test'
|
||||
import cuid from 'cuid'
|
||||
import { importTypebotInDatabase } from 'utils/playwright/databaseActions'
|
||||
import { typebotViewer } from 'utils/playwright/testHelpers'
|
||||
|
||||
const hostAvatarUrl =
|
||||
'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1760&q=80'
|
||||
const guestAvatarUrl =
|
||||
'https://images.unsplash.com/photo-1494790108377-be9c29b29330?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1287&q=80'
|
||||
|
||||
test.describe.parallel('Theme page', () => {
|
||||
test.describe('General', () => {
|
||||
test('should reflect change in real-time', async ({ page }) => {
|
||||
const typebotId = cuid()
|
||||
const chatContainer = typebotViewer(page).locator(
|
||||
'[data-testid="container"]'
|
||||
)
|
||||
await importTypebotInDatabase(getTestAsset('typebots/theme.json'), {
|
||||
id: typebotId,
|
||||
})
|
||||
await page.goto(`/typebots/${typebotId}/theme`)
|
||||
await expect(
|
||||
typebotViewer(page).locator('button >> text="Go"')
|
||||
).toBeVisible()
|
||||
|
||||
// Font
|
||||
await page.fill('input[type="text"]', 'Roboto Slab')
|
||||
await expect(chatContainer).toHaveCSS('font-family', '"Roboto Slab"')
|
||||
|
||||
// BG color
|
||||
await expect(chatContainer).toHaveCSS(
|
||||
'background-color',
|
||||
'rgba(0, 0, 0, 0)'
|
||||
)
|
||||
await page.click('text=Color')
|
||||
await page.click('[aria-label="Pick a color"]')
|
||||
await page.fill('[aria-label="Color value"] >> nth=-1', '#2a9d8f')
|
||||
await expect(chatContainer).toHaveCSS(
|
||||
'background-color',
|
||||
'rgb(42, 157, 143)'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Chat', () => {
|
||||
test('should reflect change in real-time', async ({ page }) => {
|
||||
const typebotId = 'chat-theme-typebot'
|
||||
try {
|
||||
await importTypebotInDatabase(getTestAsset('typebots/theme.json'), {
|
||||
id: typebotId,
|
||||
})
|
||||
} catch {}
|
||||
|
||||
await page.goto(`/typebots/${typebotId}/theme`)
|
||||
await expect(
|
||||
typebotViewer(page).locator('button >> text="Go"')
|
||||
).toBeVisible()
|
||||
await page.click('button:has-text("Chat")')
|
||||
|
||||
// Host avatar
|
||||
await expect(
|
||||
typebotViewer(page).locator('[data-testid="default-avatar"]')
|
||||
).toBeVisible()
|
||||
await page.click('[data-testid="default-avatar"]')
|
||||
await page.click('button:has-text("Embed link")')
|
||||
await page.fill(
|
||||
'input[placeholder="Paste the image link..."]',
|
||||
hostAvatarUrl
|
||||
)
|
||||
await typebotViewer(page).locator('button >> text="Go"').click()
|
||||
|
||||
await expect(typebotViewer(page).locator('img')).toHaveAttribute(
|
||||
'src',
|
||||
hostAvatarUrl
|
||||
)
|
||||
await page.click('text=Bot avatar')
|
||||
|
||||
await expect(typebotViewer(page).locator('img')).toBeHidden()
|
||||
|
||||
// Host bubbles
|
||||
await page.click(
|
||||
'[data-testid="host-bubbles-theme"] >> [aria-label="Pick a color"] >> nth=0'
|
||||
)
|
||||
await page.fill('input[value="#F7F8FF"]', '#2a9d8f')
|
||||
await page.click(
|
||||
'[data-testid="host-bubbles-theme"] >> [aria-label="Pick a color"] >> nth=1'
|
||||
)
|
||||
await page.fill('input[value="#303235"]', '#ffffff')
|
||||
const hostBubble = typebotViewer(page).locator(
|
||||
'[data-testid="host-bubble"] >> nth=-1'
|
||||
)
|
||||
await expect(hostBubble).toHaveCSS(
|
||||
'background-color',
|
||||
'rgb(42, 157, 143)'
|
||||
)
|
||||
await expect(hostBubble).toHaveCSS('color', 'rgb(255, 255, 255)')
|
||||
|
||||
// Buttons
|
||||
await page.click(
|
||||
'[data-testid="buttons-theme"] >> [aria-label="Pick a color"] >> nth=0'
|
||||
)
|
||||
await page.fill('input[value="#0042DA"]', '#7209b7')
|
||||
await page.click(
|
||||
'[data-testid="buttons-theme"] >> [aria-label="Pick a color"] >> nth=1'
|
||||
)
|
||||
await page.fill('input[value="#FFFFFF"]', '#e9c46a')
|
||||
const button = typebotViewer(page).locator('[data-testid="button"]')
|
||||
await expect(button).toHaveCSS('background-color', 'rgb(114, 9, 183)')
|
||||
await expect(button).toHaveCSS('color', 'rgb(233, 196, 106)')
|
||||
|
||||
// Guest bubbles
|
||||
await page.click(
|
||||
'[data-testid="guest-bubbles-theme"] >> [aria-label="Pick a color"] >> nth=0'
|
||||
)
|
||||
await page.fill('input[value="#FF8E21"]', '#d8f3dc')
|
||||
await page.click(
|
||||
'[data-testid="guest-bubbles-theme"] >> [aria-label="Pick a color"] >> nth=1'
|
||||
)
|
||||
await page.fill('input[value="#FFFFFF"]', '#264653')
|
||||
await typebotViewer(page).locator('button >> text="Go"').click()
|
||||
const guestBubble = typebotViewer(page).locator(
|
||||
'[data-testid="guest-bubble"] >> nth=-1'
|
||||
)
|
||||
await expect(guestBubble).toHaveCSS(
|
||||
'background-color',
|
||||
'rgb(216, 243, 220)'
|
||||
)
|
||||
await expect(guestBubble).toHaveCSS('color', 'rgb(38, 70, 83)')
|
||||
|
||||
// Guest avatar
|
||||
await page.click('text=User avatar')
|
||||
await expect(
|
||||
typebotViewer(page).locator('[data-testid="default-avatar"] >> nth=-1')
|
||||
).toBeVisible()
|
||||
await page.click('[data-testid="default-avatar"]')
|
||||
await page.click('button:has-text("Embed link")')
|
||||
await page.fill(
|
||||
'input[placeholder="Paste the image link..."]',
|
||||
guestAvatarUrl
|
||||
)
|
||||
|
||||
typebotViewer(page).locator('button >> text="Go"').click()
|
||||
await expect(typebotViewer(page).locator('img')).toHaveAttribute(
|
||||
'src',
|
||||
guestAvatarUrl
|
||||
)
|
||||
|
||||
// Input
|
||||
await page.click(
|
||||
'[data-testid="inputs-theme"] >> [aria-label="Pick a color"] >> nth=0'
|
||||
)
|
||||
await page.fill('input[value="#FFFFFF"]', '#ffe8d6')
|
||||
await page.click(
|
||||
'[data-testid="inputs-theme"] >> [aria-label="Pick a color"] >> nth=1'
|
||||
)
|
||||
await page.fill('input[value="#303235"]', '#023e8a')
|
||||
const input = typebotViewer(page).locator('.typebot-input')
|
||||
await expect(input).toHaveCSS('background-color', 'rgb(255, 232, 214)')
|
||||
await expect(input).toHaveCSS('color', 'rgb(2, 62, 138)')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Custom CSS', () => {
|
||||
test('should reflect change in real-time', async ({ page }) => {
|
||||
const typebotId = cuid()
|
||||
await importTypebotInDatabase(getTestAsset('typebots/theme.json'), {
|
||||
id: typebotId,
|
||||
})
|
||||
await page.goto(`/typebots/${typebotId}/theme`)
|
||||
await expect(
|
||||
typebotViewer(page).locator('button >> text="Go"')
|
||||
).toBeVisible()
|
||||
await page.click('button:has-text("Custom CSS")')
|
||||
await page.fill(
|
||||
'div[role="textbox"]',
|
||||
'.typebot-button {background-color: green}'
|
||||
)
|
||||
await expect(
|
||||
typebotViewer(page).locator('[data-testid="button"]')
|
||||
).toHaveCSS('background-color', 'rgb(0, 128, 0)')
|
||||
})
|
||||
})
|
||||
})
|
Reference in New Issue
Block a user