2
0

feat(theme): Add chat theme settings

This commit is contained in:
Baptiste Arnaud
2022-01-24 15:07:09 +01:00
parent 619d10ae4e
commit b0abe5b8fa
37 changed files with 771 additions and 375 deletions

View File

@ -17,7 +17,7 @@ export const SettingsContent = () => {
<Flex h="full" w="full" justifyContent="center" align="flex-start">
<Stack p="6" rounded="md" borderWidth={1} w="600px" minH="500px" mt={10}>
<TypingEmulation
typingEmulation={typebot?.settings.typingEmulation}
typingEmulation={typebot?.settings?.typingEmulation}
onUpdate={handleTypingEmulationUpdate}
/>
</Stack>

View File

@ -0,0 +1,38 @@
import { Stack, Flex, Text } from '@chakra-ui/react'
import { ContainerColors } from 'models'
import React from 'react'
import { ColorPicker } from '../GeneralSettings/ColorPicker'
type Props = {
buttons?: ContainerColors
onButtonsChange: (buttons: ContainerColors) => void
}
const defaultBackgroundColor = '#0042da'
const defaultTextColor = '#ffffff'
export const ButtonsTheme = ({ buttons, onButtonsChange }: Props) => {
const handleBackgroundChange = (backgroundColor: string) =>
onButtonsChange({ ...buttons, backgroundColor })
const handleTextChange = (color: string) =>
onButtonsChange({ ...buttons, color })
return (
<Stack>
<Flex justify="space-between" align="center">
<Text>Background:</Text>
<ColorPicker
initialColor={buttons?.backgroundColor ?? defaultBackgroundColor}
onColorChange={handleBackgroundChange}
/>
</Flex>
<Flex justify="space-between" align="center">
<Text>Text:</Text>
<ColorPicker
initialColor={buttons?.color ?? defaultTextColor}
onColorChange={handleTextChange}
/>
</Flex>
</Stack>
)
}

View File

@ -0,0 +1,56 @@
import { Heading, Stack } from '@chakra-ui/react'
import { ChatTheme, ContainerColors, InputColors } from 'models'
import React from 'react'
import { ButtonsTheme } from './ButtonsTheme'
import { GuestBubbles } from './GuestBubbles'
import { HostBubbles } from './HostBubbles'
import { InputsTheme } from './InputsTheme'
type Props = {
chatTheme?: ChatTheme
onChatThemeChange: (chatTheme: ChatTheme) => void
}
export const ChatThemeSettings = ({ 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 })
return (
<Stack spacing={6}>
<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>
)
}

View File

@ -0,0 +1,37 @@
import { Stack, Flex, Text } from '@chakra-ui/react'
import { ContainerColors } from 'models'
import React from 'react'
import { ColorPicker } from '../GeneralSettings/ColorPicker'
type Props = {
guestBubbles?: ContainerColors
onGuestBubblesChange: (hostBubbles: ContainerColors) => void
}
const defaultBackgroundColor = '#ff8e21'
const defaultTextColor = '#ffffff'
export const GuestBubbles = ({ guestBubbles, onGuestBubblesChange }: Props) => {
const handleBackgroundChange = (backgroundColor: string) =>
onGuestBubblesChange({ ...guestBubbles, backgroundColor })
const handleTextChange = (color: string) =>
onGuestBubblesChange({ ...guestBubbles, color })
return (
<Stack>
<Flex justify="space-between" align="center">
<Text>Background:</Text>
<ColorPicker
initialColor={guestBubbles?.backgroundColor ?? defaultBackgroundColor}
onColorChange={handleBackgroundChange}
/>
</Flex>
<Flex justify="space-between" align="center">
<Text>Text:</Text>
<ColorPicker
initialColor={guestBubbles?.color ?? defaultTextColor}
onColorChange={handleTextChange}
/>
</Flex>
</Stack>
)
}

View File

@ -0,0 +1,38 @@
import { Stack, Flex, Text } from '@chakra-ui/react'
import { ContainerColors } from 'models'
import React from 'react'
import { ColorPicker } from '../GeneralSettings/ColorPicker'
type Props = {
hostBubbles?: ContainerColors
onHostBubblesChange: (hostBubbles: ContainerColors) => void
}
const defaultBackgroundColor = '#f7f8ff'
const defaultTextColor = '#303235'
export const HostBubbles = ({ hostBubbles, onHostBubblesChange }: Props) => {
const handleBackgroundChange = (backgroundColor: string) =>
onHostBubblesChange({ ...hostBubbles, backgroundColor })
const handleTextChange = (color: string) =>
onHostBubblesChange({ ...hostBubbles, color })
return (
<Stack>
<Flex justify="space-between" align="center">
<Text>Background:</Text>
<ColorPicker
initialColor={hostBubbles?.backgroundColor ?? defaultBackgroundColor}
onColorChange={handleBackgroundChange}
/>
</Flex>
<Flex justify="space-between" align="center">
<Text>Text:</Text>
<ColorPicker
initialColor={hostBubbles?.color ?? defaultTextColor}
onColorChange={handleTextChange}
/>
</Flex>
</Stack>
)
}

View File

@ -0,0 +1,48 @@
import { Stack, Flex, Text } from '@chakra-ui/react'
import { InputColors } from 'models'
import React from 'react'
import { ColorPicker } from '../GeneralSettings/ColorPicker'
type Props = {
inputs?: InputColors
onInputsChange: (buttons: InputColors) => void
}
const defaultBackgroundColor = '#ffffff'
const defaultTextColor = '#303235'
const defaultPlaceholderColor = '#9095A0'
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>
<Flex justify="space-between" align="center">
<Text>Background:</Text>
<ColorPicker
initialColor={inputs?.backgroundColor ?? defaultBackgroundColor}
onColorChange={handleBackgroundChange}
/>
</Flex>
<Flex justify="space-between" align="center">
<Text>Text:</Text>
<ColorPicker
initialColor={inputs?.color ?? defaultTextColor}
onColorChange={handleTextChange}
/>
</Flex>
<Flex justify="space-between" align="center">
<Text>Placeholder text:</Text>
<ColorPicker
initialColor={inputs?.placeholderColor ?? defaultPlaceholderColor}
onColorChange={handlePlaceholderChange}
/>
</Flex>
</Stack>
)
}

View File

@ -0,0 +1 @@
export { ChatThemeSettings } from './ChatThemeSettings'

View File

@ -1,64 +0,0 @@
import {
AccordionItem,
AccordionButton,
HStack,
Heading,
AccordionIcon,
AccordionPanel,
Stack,
} from '@chakra-ui/react'
import { PencilIcon } from 'assets/icons'
import { Background } from 'models'
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
import React from 'react'
import { BackgroundSelector } from './BackgroundSelector'
import { FontSelector } from './FontSelector'
export const GeneralContent = () => {
const { typebot, updateTypebot } = useTypebot()
const handleSelectFont = (font: string) => {
if (!typebot) return
updateTypebot({
theme: {
...typebot.theme,
general: { ...typebot.theme.general, font },
},
})
}
const handleBackgroundChange = (background: Background) => {
if (!typebot) return
updateTypebot({
theme: {
...typebot.theme,
general: { ...typebot.theme.general, background },
},
})
}
return (
<AccordionItem>
<AccordionButton py={6}>
<HStack flex="1" pl={2}>
<PencilIcon />
<Heading fontSize="lg" color="gray.700">
General
</Heading>
</HStack>
<AccordionIcon />
</AccordionButton>
{typebot && (
<AccordionPanel as={Stack} pb={4} spacing={6}>
<FontSelector
activeFont={typebot.theme.general.font}
onSelectFont={handleSelectFont}
/>
<BackgroundSelector
initialBackground={typebot.theme.general.background}
onBackgroundChange={handleBackgroundChange}
/>
</AccordionPanel>
)}
</AccordionItem>
)
}

View File

@ -1 +0,0 @@
export { GeneralContent } from './GeneralContent'

View File

@ -4,10 +4,12 @@ import React from 'react'
import { ColorPicker } from '../ColorPicker'
type BackgroundContentProps = {
background: Background
background?: Background
onBackgroundContentChange: (content: string) => void
}
const defaultBackgroundColor = '#ffffff'
export const BackgroundContent = ({
background,
onBackgroundContentChange,
@ -15,13 +17,13 @@ export const BackgroundContent = ({
const handleContentChange = (content: string) =>
onBackgroundContentChange(content)
switch (background.type) {
switch (background?.type) {
case BackgroundType.COLOR:
return (
<Flex justify="space-between" align="center">
<Text>Background color:</Text>
<ColorPicker
initialColor={background.content}
initialColor={background.content ?? defaultBackgroundColor}
onColorChange={handleContentChange}
/>
</Flex>

View File

@ -1,43 +1,35 @@
import { Stack, Text } from '@chakra-ui/react'
import { Background, BackgroundType } from 'models'
import { deepEqual } from 'fast-equals'
import React, { useEffect, useState } from 'react'
import React from 'react'
import { BackgroundContent } from './BackgroundContent'
import { BackgroundTypeRadioButtons } from './BackgroundTypeRadioButtons'
type Props = {
initialBackground?: Background
background?: Background
onBackgroundChange: (newBackground: Background) => void
}
const defaultBackgroundType = BackgroundType.NONE
export const BackgroundSelector = ({
initialBackground,
background,
onBackgroundChange,
}: Props) => {
const [currentBackground, setCurrentBackground] = useState<Background>(
initialBackground ?? { type: BackgroundType.NONE, content: '' }
)
useEffect(() => {
if (deepEqual(currentBackground, initialBackground)) return
onBackgroundChange(currentBackground)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentBackground])
const handleBackgroundTypeChange = (type: BackgroundType) =>
setCurrentBackground({ ...currentBackground, type })
background && onBackgroundChange({ ...background, type })
const handleBackgroundContentChange = (content: string) =>
setCurrentBackground({ ...currentBackground, content })
background && onBackgroundChange({ ...background, content })
return (
<Stack spacing={4}>
<Text>Background</Text>
<BackgroundTypeRadioButtons
backgroundType={currentBackground.type}
backgroundType={background?.type ?? defaultBackgroundType}
onBackgroundTypeChange={handleBackgroundTypeChange}
/>
<BackgroundContent
background={currentBackground}
background={background}
onBackgroundContentChange={handleBackgroundContentChange}
/>
</Stack>

View File

@ -0,0 +1,36 @@
import { Stack } from '@chakra-ui/react'
import { Background, BackgroundType, GeneralTheme } from 'models'
import React from 'react'
import { BackgroundSelector } from './BackgroundSelector'
import { FontSelector } from './FontSelector'
type Props = {
generalTheme?: GeneralTheme
onGeneralThemeChange: (general: GeneralTheme) => void
}
const defaultFont = 'Open Sans'
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 ?? defaultFont}
onSelectFont={handleSelectFont}
/>
<BackgroundSelector
background={generalTheme?.background ?? { type: BackgroundType.NONE }}
onBackgroundChange={handleBackgroundChange}
/>
</Stack>
)
}

View File

@ -0,0 +1 @@
export { GeneralSettings } from './GeneralSettings'

View File

@ -8,26 +8,58 @@ import {
HStack,
Stack,
} from '@chakra-ui/react'
import { ChatIcon, CodeIcon, LayoutIcon } from 'assets/icons'
import { ChatIcon, CodeIcon, LayoutIcon, PencilIcon } from 'assets/icons'
import { headerHeight } from 'components/shared/TypebotHeader'
import { useTypebot } from 'contexts/TypebotContext'
import { ChatTheme, GeneralTheme } from 'models'
import React from 'react'
import { GeneralContent } from './GeneralContent'
import { ChatThemeSettings } from './ChatSettings'
import { GeneralSettings } from './GeneralSettings'
export const SideMenu = () => {
const { typebot, updateTypebot } = useTypebot()
const handleChatThemeChange = (chat: ChatTheme) =>
updateTypebot({ theme: { ...typebot?.theme, chat } })
const handleGeneralThemeChange = (general: GeneralTheme) =>
updateTypebot({ theme: { ...typebot?.theme, general } })
return (
<Stack flex="1" maxW="400px" borderRightWidth={1} pt={10} spacing={10}>
<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 allowToggle>
<GeneralContent />
<AccordionItem>
<AccordionButton py={6}>
<HStack flex="1" pl={2}>
<PencilIcon />
<Heading fontSize="lg">General</Heading>
</HStack>
<AccordionIcon />
</AccordionButton>
<AccordionPanel pb={4}>
<GeneralSettings
generalTheme={typebot?.theme?.general}
onGeneralThemeChange={handleGeneralThemeChange}
/>
</AccordionPanel>
</AccordionItem>
<AccordionItem>
<AccordionButton py={6}>
<HStack flex="1" pl={2}>
<LayoutIcon />
<Heading fontSize="lg" color="gray.700">
Layout
</Heading>
<Heading fontSize="lg">Layout</Heading>
</HStack>
<AccordionIcon />
</AccordionButton>
@ -42,26 +74,22 @@ export const SideMenu = () => {
<AccordionButton py={6}>
<HStack flex="1" pl={2}>
<ChatIcon />
<Heading fontSize="lg" color="gray.700">
Chat
</Heading>
<Heading fontSize="lg">Chat</Heading>
</HStack>
<AccordionIcon />
</AccordionButton>
<AccordionPanel pb={4}>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do
eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim
ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut
aliquip ex ea commodo consequat.
<ChatThemeSettings
chatTheme={typebot?.theme?.chat}
onChatThemeChange={handleChatThemeChange}
/>
</AccordionPanel>
</AccordionItem>
<AccordionItem>
<AccordionButton py={6}>
<HStack flex="1" pl={2}>
<CodeIcon />
<Heading fontSize="lg" color="gray.700">
Custom CSS
</Heading>
<Heading fontSize="lg">Custom CSS</Heading>
</HStack>
<AccordionIcon />
</AccordionButton>

View File

@ -0,0 +1,148 @@
{
"id": "typebot4",
"createdAt": "2022-01-21T07:55:14.727Z",
"updatedAt": "2022-01-21T07:55:14.727Z",
"name": "My typebot",
"ownerId": "user2",
"publishedTypebotId": null,
"folderId": null,
"blocks": {
"byId": {
"3kH2sUjVThQDWmqdoKnGk5": {
"id": "3kH2sUjVThQDWmqdoKnGk5",
"title": "Start",
"stepIds": ["oxTsU2C1RX5QHuyY8qjHAM"],
"graphCoordinates": { "x": 42, "y": 13 }
},
"bdFW2HHjMoEFmqHtFre9Xi8": {
"id": "bdFW2HHjMoEFmqHtFre9Xi8",
"title": "Block #2",
"stepIds": ["sgkADMK25y9P9V3vjwjBaac", "ssEiEECKSFkA44dGDceHxKw"],
"graphCoordinates": { "x": 121, "y": 227 }
},
"bhKHKi1SQb5woZEy1y4fNsJ": {
"id": "bhKHKi1SQb5woZEy1y4fNsJ",
"title": "Block #3",
"graphCoordinates": { "x": 605, "y": 454 },
"stepIds": ["sseUQEWCMdiZquk8EbxHYtk"]
}
},
"allIds": [
"3kH2sUjVThQDWmqdoKnGk5",
"bdFW2HHjMoEFmqHtFre9Xi8",
"bhKHKi1SQb5woZEy1y4fNsJ"
]
},
"steps": {
"byId": {
"oxTsU2C1RX5QHuyY8qjHAM": {
"id": "oxTsU2C1RX5QHuyY8qjHAM",
"type": "start",
"label": "Start",
"edgeId": "25yX9DnQgdafpdAjfAu5Fp",
"blockId": "3kH2sUjVThQDWmqdoKnGk5"
},
"sgkADMK25y9P9V3vjwjBaac": {
"id": "sgkADMK25y9P9V3vjwjBaac",
"type": "text",
"blockId": "bdFW2HHjMoEFmqHtFre9Xi8",
"content": {
"html": "<div>Ready?</div>",
"richText": [{ "type": "p", "children": [{ "text": "Ready?" }] }],
"plainText": "Ready?"
}
},
"ssEiEECKSFkA44dGDceHxKw": {
"id": "ssEiEECKSFkA44dGDceHxKw",
"type": "choice input",
"edgeId": "6e4Sbp8pGTvBQYtCk2qXbN",
"blockId": "bdFW2HHjMoEFmqHtFre9Xi8",
"options": { "itemIds": ["q69Ex7LacPrH9QUMeosRnB"] }
},
"sseUQEWCMdiZquk8EbxHYtk": {
"id": "sseUQEWCMdiZquk8EbxHYtk",
"blockId": "bhKHKi1SQb5woZEy1y4fNsJ",
"type": "text input"
}
},
"allIds": [
"oxTsU2C1RX5QHuyY8qjHAM",
"sgkADMK25y9P9V3vjwjBaac",
"ssEiEECKSFkA44dGDceHxKw",
"sseUQEWCMdiZquk8EbxHYtk"
]
},
"choiceItems": {
"byId": {
"q69Ex7LacPrH9QUMeosRnB": {
"id": "q69Ex7LacPrH9QUMeosRnB",
"stepId": "ssEiEECKSFkA44dGDceHxKw",
"content": "Go"
}
},
"allIds": ["q69Ex7LacPrH9QUMeosRnB"]
},
"variables": {
"byId": {
"4tvkRmf32wiTsXrYoqyhfr": {
"id": "4tvkRmf32wiTsXrYoqyhfr",
"name": "secret 2"
},
"jEg1FvkCU5S5owNAxXFsHL": {
"id": "jEg1FvkCU5S5owNAxXFsHL",
"name": "secret 3"
},
"oASkBtoLqkYNqeakcjZH4L": {
"id": "oASkBtoLqkYNqeakcjZH4L",
"name": "secret 1"
},
"rEoE1ehHzgx8X3d3UPGDHg": {
"id": "rEoE1ehHzgx8X3d3UPGDHg",
"name": "secret 4"
}
},
"allIds": [
"oASkBtoLqkYNqeakcjZH4L",
"4tvkRmf32wiTsXrYoqyhfr",
"jEg1FvkCU5S5owNAxXFsHL",
"rEoE1ehHzgx8X3d3UPGDHg"
]
},
"webhooks": {
"byId": {
"4h4Kk3Q1qGy7gFzpZtWVpU": { "id": "4h4Kk3Q1qGy7gFzpZtWVpU", "url": "" }
},
"allIds": ["4h4Kk3Q1qGy7gFzpZtWVpU"]
},
"edges": {
"byId": {
"25yX9DnQgdafpdAjfAu5Fp": {
"id": "25yX9DnQgdafpdAjfAu5Fp",
"to": { "blockId": "bdFW2HHjMoEFmqHtFre9Xi8" },
"from": {
"stepId": "oxTsU2C1RX5QHuyY8qjHAM",
"blockId": "3kH2sUjVThQDWmqdoKnGk5"
}
},
"6e4Sbp8pGTvBQYtCk2qXbN": {
"from": {
"blockId": "bdFW2HHjMoEFmqHtFre9Xi8",
"stepId": "ssEiEECKSFkA44dGDceHxKw"
},
"to": { "blockId": "bhKHKi1SQb5woZEy1y4fNsJ" },
"id": "6e4Sbp8pGTvBQYtCk2qXbN"
}
},
"allIds": ["25yX9DnQgdafpdAjfAu5Fp", "6e4Sbp8pGTvBQYtCk2qXbN"]
},
"theme": {
"general": {
"font": "Open Sans",
"background": { "type": "None", "content": "#ffffff" }
}
},
"settings": {
"typingEmulation": { "speed": 300, "enabled": true, "maxDelay": 1.5 }
},
"publicId": null
}

View File

@ -0,0 +1,90 @@
import { getIframeBody } from 'cypress/support'
describe('General theme settings', () => {
beforeEach(() => {
cy.task('seed')
cy.signOut()
})
it('should reflect changes in real time', () => {
cy.loadTypebotFixtureInDatabase('typebots/theme/theme.json')
cy.signIn('test2@gmail.com')
cy.visit('/typebots/typebot4/theme')
getIframeBody().findByText('Ready?').should('exist')
cy.findByRole('button', { name: 'Chat' }).click()
// Host bubbles
cy.findAllByRole('button', { name: 'Pick a color' }).first().click()
cy.findByRole('textbox', { name: 'Color value' }).clear().type('#2a9d8f')
cy.findAllByRole('button', { name: 'Pick a color' })
.eq(1)
.click({ force: true })
cy.findByRole('textbox', { name: 'Color value' }).clear().type('#ffffff')
getIframeBody()
.findByTestId('host-bubble')
.should('have.css', 'background-color')
.should('eq', 'rgb(42, 157, 143)')
getIframeBody()
.findByTestId('host-bubble')
.should('have.css', 'color')
.should('eq', 'rgb(255, 255, 255)')
// Buttons
cy.findAllByRole('button', { name: 'Pick a color' })
.eq(4)
.click({ force: true })
cy.findByRole('textbox', { name: 'Color value' }).clear().type('#7209b7')
cy.findAllByRole('button', { name: 'Pick a color' })
.eq(5)
.click({ force: true })
cy.findByRole('textbox', { name: 'Color value' }).clear().type('#e9c46a')
getIframeBody()
.findByTestId('button')
.should('have.css', 'background-color')
.should('eq', 'rgb(114, 9, 183)')
getIframeBody()
.findByTestId('button')
.should('have.css', 'color')
.should('eq', 'rgb(233, 196, 106)')
// Guest bubbles
cy.findAllByRole('button', { name: 'Pick a color' })
.eq(2)
.click({ force: true })
cy.findByRole('textbox', { name: 'Color value' }).clear().type('#d8f3dc')
cy.findAllByRole('button', { name: 'Pick a color' })
.eq(3)
.click({ force: true })
cy.findByRole('textbox', { name: 'Color value' }).clear().type('#264653')
cy.findAllByRole('button', { name: 'Pick a color' })
.eq(6)
.click({ force: true })
getIframeBody().findByRole('button', { name: 'Go' }).click()
getIframeBody()
.findByTestId('guest-bubble')
.should('have.css', 'background-color')
.should('eq', 'rgb(216, 243, 220)')
getIframeBody()
.findByTestId('guest-bubble')
.should('have.css', 'color')
.should('eq', 'rgb(38, 70, 83)')
cy.findByRole('textbox', { name: 'Color value' }).clear().type('#ffe8d6')
cy.findAllByRole('button', { name: 'Pick a color' })
.eq(7)
.click({ force: true })
cy.findByRole('textbox', { name: 'Color value' }).clear().type('#023e8a')
cy.findAllByRole('button', { name: 'Pick a color' })
.eq(8)
.click({ force: true })
cy.findByRole('textbox', { name: 'Color value' }).clear().type('red')
getIframeBody()
.findByTestId('input')
.should('have.css', 'background-color')
.should('eq', 'rgb(255, 232, 214)')
getIframeBody()
.findByTestId('input')
.should('have.css', 'color')
.should('eq', 'rgb(2, 61, 138)')
})
})

View File

@ -6,8 +6,8 @@ describe('General theme settings', () => {
cy.signOut()
})
it.only('should reflect changes in real time', () => {
cy.loadTypebotFixtureInDatabase('typebots/integrations/webhook.json')
it('should reflect changes in real time', () => {
cy.loadTypebotFixtureInDatabase('typebots/theme/theme.json')
cy.signIn('test2@gmail.com')
cy.visit('/typebots/typebot4/theme')
cy.findByRole('button', { name: 'General' }).click()

View File

@ -2,10 +2,7 @@ import {
Block,
TextBubbleStep,
PublicTypebot,
BackgroundType,
Settings,
StartStep,
Theme,
BubbleStepType,
InputStepType,
ChoiceInputStep,
@ -216,19 +213,6 @@ export const parseNewTypebot = ({
graphCoordinates: { x: 0, y: 0 },
stepIds: [startStepId],
}
const theme: Theme = {
general: {
font: 'Open Sans',
background: { type: BackgroundType.NONE, content: '#ffffff' },
},
}
const settings: Settings = {
typingEmulation: {
enabled: true,
speed: 300,
maxDelay: 1.5,
},
}
return {
folderId,
name,
@ -239,8 +223,6 @@ export const parseNewTypebot = ({
variables: { byId: {}, allIds: [] },
edges: { byId: {}, allIds: [] },
webhooks: { byId: {}, allIds: [] },
theme,
settings,
}
}

View File

@ -0,0 +1,13 @@
.PhoneInputInput {
padding: 1rem 0.5rem;
outline: none !important;
}
.PhoneInputCountry {
padding-left: 0.5rem;
}
.PhoneInputCountryIcon,
.PhoneInputCountryIconImg {
border-radius: 3px;
}

View File

@ -6,38 +6,22 @@
--typebot-container-bg-image: none;
--typebot-container-bg-color: transparent;
--typebot-container-font-family: 'Open Sans';
--typebot-chat-view-max-width: 700px;
--typebot-chat-view-color: #303235;
--typebot-button-active-bg-color: #0042da;
--typebot-button-active-color: #ffffff;
--typebot-button-inactive-bg-color: #edf2f7;
--typebot-button-inactive-color: #303235;
--typebot-button-border: 1px solid var(--typebot-button-active-bg-color);
--typebot-button-shadow: none;
--typebot-button-bg-color: #0042da;
--typebot-button-color: #ffffff;
--typebot-host-bubble-bg-color: #f7f8ff;
--typebot-host-bubble-color: #303235;
--typebot-host-bubble-border: 1px solid var(--typebot-host-bubble-bg-color);
--typebot-host-bubble-shadow: none;
--typebot-guest-bubble-bg-color: #ff8e21;
--typebot-guest-bubble-color: #ffffff;
--typebot-guest-bubble-border: 1px solid var(--typebot-guest-bubble-bg-color);
--typebot-guest-bubble-shadow: none;
--typebot-input-bg-color: #ffffff;
--typebot-input-color: #303235;
--typebot-input-border: 1px solid var(--typebot-input-bg-color);
--typebot-input-shadow: 0 2px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
--typebot-input-placeholder-color: #9095a0;
--typebot-header-bg-color: #ffffff;
--typebot-header-color: #303235;
--typebot-header-border: none;
--typebot-header-shadow: none;
--typebot-header-max-width: 1000px;
/* Phone input */
--PhoneInputCountryFlag-borderColor: transparent;
@ -55,12 +39,6 @@
scrollbar-width: none; /* Firefox */
}
.StripeElement {
box-sizing: border-box;
height: 40px;
padding: 10px 12px;
}
/* Transitions */
.bubble-enter {
opacity: 0;
@ -149,159 +127,19 @@ textarea {
text-decoration: underline;
}
.comp-input::-webkit-input-placeholder {
/* Chrome/Opera/Safari */
.text-input::-webkit-input-placeholder {
color: var(--typebot-input-placeholder-color) !important;
opacity: 1 !important;
}
.comp-input::-moz-placeholder {
/* Firefox 19+ */
.text-input::-moz-placeholder {
color: var(--typebot-input-placeholder-color) !important;
opacity: 1 !important;
}
.comp-input::placeholder {
.text-input::placeholder {
color: var(--typebot-input-placeholder-color) !important;
opacity: 1 !important;
}
.PhoneInput {
/* This is done to stretch the contents of this component. */
display: flex;
align-items: center;
}
.PhoneInput > input {
color: var(--typebot-input-color);
background-color: var(--typebot-input-bg-color);
}
.PhoneInput > input::placeholder {
color: var(--typebot-input-placeholder-color) !important;
}
.PhoneInputInput {
/* The phone number input stretches to fill all empty space */
flex: 1;
/* The phone number input should shrink
to make room for the extension input */
min-width: 0;
padding: 1rem 1rem 1rem 0;
}
.PhoneInputInput:focus {
outline: none;
}
.PhoneInputCountryIcon {
width: calc(
var(--PhoneInputCountryFlag-height) *
var(--PhoneInputCountryFlag-aspectRatio)
);
height: var(--PhoneInputCountryFlag-height);
box-shadow: none;
}
.PhoneInputCountryIcon--square {
width: var(--PhoneInputCountryFlag-height);
}
.PhoneInputCountryIcon--border {
/* Removed `background-color` because when an `<img/>` was still loading
it would show a dark gray rectangle. */
/* For some reason the `<img/>` is not stretched to 100% width and height
and sometime there can be seen white pixels of the background at top and bottom. */
background-color: var(--PhoneInputCountryFlag-backgroundColor--loading);
/* Border is added via `box-shadow` because `border` interferes with `width`/`height`. */
/* For some reason the `<img/>` is not stretched to 100% width and height
and sometime there can be seen white pixels of the background at top and bottom,
so an additional "inset" border is added. */
box-shadow: 0 0 0 var(--PhoneInputCountryFlag-borderWidth)
var(--PhoneInputCountryFlag-borderColor),
inset 0 0 0 var(--PhoneInputCountryFlag-borderWidth)
var(--PhoneInputCountryFlag-borderColor);
}
.PhoneInputCountryIconImg {
/* Fixes weird vertical space above the flag icon. */
/* https://gitlab.com/catamphetamine/react-phone-number-input/-/issues/7#note_348586559 */
display: block;
/* 3rd party <SVG/> flag icons won't stretch if they have `width` and `height`.
Also, if an <SVG/> icon's aspect ratio was different, it wouldn't fit too. */
width: 100%;
height: 100%;
border-radius: 5px;
border: none;
}
.PhoneInputInternationalIconPhone {
opacity: var(--PhoneInputInternationalIconPhone-opacity);
}
.PhoneInputInternationalIconGlobe {
opacity: var(--PhoneInputInternationalIconGlobe-opacity);
}
/* Styling native country `<select/>`. */
.PhoneInputCountry {
position: relative;
align-self: stretch;
display: flex;
align-items: center;
padding: 0 1rem;
margin-right: 0;
}
.PhoneInputCountrySelect {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
z-index: 1;
border: 0;
opacity: 0;
cursor: pointer;
}
.PhoneInputCountrySelect[disabled] {
cursor: default;
}
.PhoneInputCountrySelectArrow {
display: block;
content: '';
width: var(--PhoneInputCountrySelectArrow-width);
height: var(--PhoneInputCountrySelectArrow-width);
margin-left: var(--PhoneInputCountrySelectArrow-marginLeft);
border-style: solid;
border-color: var(--PhoneInputCountrySelectArrow-color);
border-top-width: 0;
border-bottom-width: var(--PhoneInputCountrySelectArrow-borderWidth);
border-left-width: 0;
border-right-width: var(--PhoneInputCountrySelectArrow-borderWidth);
transform: var(--PhoneInputCountrySelectArrow-transform);
opacity: var(--PhoneInputCountrySelectArrow-opacity);
}
.PhoneInputCountrySelect:focus
+ .PhoneInputCountryIcon
+ .PhoneInputCountrySelectArrow {
opacity: 1;
color: var(--PhoneInputCountrySelectArrow-color--focus);
}
.PhoneInputCountrySelect:focus + .PhoneInputCountryIcon--border {
box-shadow: none;
}
.PhoneInputCountrySelect:focus
+ .PhoneInputCountryIcon
.PhoneInputInternationalIconGlobe {
opacity: 1;
color: var(--PhoneInputCountrySelectArrow-color--focus);
}
.typebot-container {
background-image: var(--typebot-container-bg-image);
background-color: var(--typebot-container-bg-color);
@ -311,28 +149,11 @@ textarea {
.custom-header {
color: var(--typebot-header-color);
background-color: var(--typebot-header-bg-color);
border-bottom: var(--typebot-header-border);
box-shadow: var(--typebot-header-shadow);
}
.custom-header-content {
max-width: var(--typebot-header-max-width);
}
.typebot-chat-view {
max-width: var(--typebot-chat-view-max-width);
}
.typebot-button.active {
color: var(--typebot-button-active-color);
background-color: var(--typebot-button-active-bg-color);
}
.typebot-button {
color: var(--typebot-button-inactive-color);
background-color: var(--typebot-button-inactive-bg-color);
border: var(--typebot-button-border);
box-shadow: var(--typebot-button-shadow);
color: var(--typebot-button-color);
background-color: var(--typebot-button-bg-color);
}
.typebot-host-bubble {
@ -342,45 +163,23 @@ textarea {
.typebot-host-bubble > .bubble-typing {
background-color: var(--typebot-host-bubble-bg-color);
border: var(--typebot-host-bubble-border);
box-shadow: var(--typebot-host-bubble-shadow);
}
.typebot-guest-bubble {
color: var(--typebot-guest-bubble-color);
background-color: var(--typebot-guest-bubble-bg-color);
border: var(--typebot-guest-bubble-border);
box-shadow: var(--typebot-guest-bubble-shadow);
}
.typebot-input {
color: var(--typebot-input-color);
background-color: var(--typebot-input-bg-color);
border: var(--typebot-input-border);
box-shadow: var(--typebot-input-shadow);
box-shadow: 0 2px 6px -1px rgba(0, 0, 0, 0.1);
}
.typebot-button > .send-icon {
fill: var(--typebot-button-active-color);
}
.text-on-chat {
color: var(--typebot-chat-view-color);
}
.star-icon {
cursor: pointer;
fill: transparent;
stroke-width: 30px;
stroke: var(--typebot-button-active-bg-color);
}
.star-icon.active {
fill: var(--typebot-button-active-bg-color);
}
.scale-labels {
font-size: 13px;
color: var(--typebot-button-active-bg-color);
font-weight: 600;
padding-right: 0.5rem;
.typebot-chat-view {
max-width: 800px;
}

View File

@ -10,7 +10,10 @@ export const GuestBubble = ({ message }: Props): JSX.Element => {
<CSSTransition classNames="bubble" timeout={1000}>
<div className="flex justify-end mb-2 items-center">
<div className="flex items-end w-11/12 lg:w-4/6 justify-end">
<div className="inline-flex px-4 py-2 rounded-lg mr-2 whitespace-pre-wrap max-w-full typebot-guest-bubble">
<div
className="inline-flex px-4 py-2 rounded-lg mr-2 whitespace-pre-wrap max-w-full typebot-guest-bubble"
data-testid="guest-bubble"
>
{message}
</div>
</div>

View File

@ -13,9 +13,14 @@ type Props = {
export const showAnimationDuration = 400
const defaultTypingEmulation = {
enabled: true,
speed: 300,
maxDelay: 1.5,
}
export const TextBubble = ({ step, onTransitionEnd }: Props) => {
const { typebot } = useTypebot()
const { typingEmulation } = typebot.settings
const { updateLastAvatarOffset } = useHostAvatars()
const messageContainer = useRef<HTMLDivElement | null>(null)
const [isTyping, setIsTyping] = useState(true)
@ -30,7 +35,7 @@ export const TextBubble = ({ step, onTransitionEnd }: Props) => {
sendAvatarOffset()
const typingTimeout = computeTypingTimeout(
step.content.plainText,
typingEmulation
typebot.settings?.typingEmulation ?? defaultTypingEmulation
)
setTimeout(() => {
onTypingEnd()
@ -61,6 +66,7 @@ export const TextBubble = ({ step, onTransitionEnd }: Props) => {
width: isTyping ? '4rem' : '100%',
height: isTyping ? '2rem' : '100%',
}}
data-testid="host-bubble"
>
{isTyping ? <TypingContent /> : <></>}
</div>

View File

@ -49,6 +49,7 @@ export const ChoiceForm = ({ options, onSubmit }: ChoiceFormProps) => {
? 'active'
: '')
}
data-testid="button"
>
{items.byId[itemId].content}
</button>

View File

@ -34,7 +34,7 @@ export const DateForm = ({
<p className="font-semibold mr-2">{labels?.from ?? 'From:'}</p>
)}
<input
className="focus:outline-none bg-transparent flex-1 w-full comp-input"
className="focus:outline-none bg-transparent flex-1 w-full text-input"
type={hasTime ? 'datetime-local' : 'date'}
onChange={(e) =>
setInputValues({ ...inputValues, from: e.target.value })
@ -48,7 +48,7 @@ export const DateForm = ({
<p className="font-semibold">{labels?.to ?? 'To:'}</p>
)}
<input
className="focus:outline-none bg-transparent flex-1 w-full comp-input ml-2"
className="focus:outline-none bg-transparent flex-1 w-full text-input ml-2"
type={hasTime ? 'datetime-local' : 'date'}
onChange={(e) =>
setInputValues({ ...inputValues, to: e.target.value })

View File

@ -36,6 +36,7 @@ export const TextForm = ({ step, onSubmit }: TextFormProps) => {
<form
className="flex items-end justify-between rounded-lg pr-2 typebot-input"
onSubmit={handleSubmit}
data-testid="input"
>
<TextInput step={step} onChange={handleChange} />
<SendButton

View File

@ -120,7 +120,7 @@ const ShortTextInput = React.forwardRef(
) => (
<input
ref={ref}
className="focus:outline-none bg-transparent px-4 py-4 flex-1 w-full comp-input"
className="focus:outline-none bg-transparent px-4 py-4 flex-1 w-full text-input"
type="text"
required
{...props}
@ -135,7 +135,7 @@ const LongTextInput = React.forwardRef(
) => (
<textarea
ref={ref}
className="focus:outline-none bg-transparent px-4 py-4 flex-1 w-full comp-input"
className="focus:outline-none bg-transparent px-4 py-4 flex-1 w-full text-input"
rows={4}
data-testid="textarea"
required

View File

@ -5,6 +5,8 @@ import Frame from 'react-frame-component'
import style from '../assets/style.css'
//@ts-ignore
import phoneNumberInputStyle from 'react-phone-number-input/style.css'
//@ts-ignore
import phoneSyle from '../assets/phone.css'
import { ConversationContainer } from './ConversationContainer'
import { AnswersContext } from '../contexts/AnswersContext'
import { Answer, BackgroundType, PublicTypebot } from 'models'
@ -23,10 +25,10 @@ export const TypebotViewer = ({
}: TypebotViewerProps) => {
const containerBgColor = useMemo(
() =>
typebot.theme.general.background.type === BackgroundType.COLOR
typebot?.theme?.general?.background?.type === BackgroundType.COLOR
? typebot.theme.general.background.content
: 'transparent',
[typebot.theme.general.background]
[typebot?.theme?.general?.background]
)
const handleNewBlockVisible = (blockId: string) => {
if (onNewBlockVisible) onNewBlockVisible(blockId)
@ -44,6 +46,7 @@ export const TypebotViewer = ({
head={
<style>
{phoneNumberInputStyle}
{phoneSyle}
{style}
</style>
}
@ -51,7 +54,9 @@ export const TypebotViewer = ({
>
<style
dangerouslySetInnerHTML={{
__html: `@import url('https://fonts.googleapis.com/css2?family=${typebot.theme.general.font}:wght@300;400;600&display=swap');`,
__html: `@import url('https://fonts.googleapis.com/css2?family=${
typebot?.theme?.general?.font ?? 'Open Sans'
}:wght@300;400;600&display=swap');`,
}}
/>
<TypebotContext typebot={typebot}>
@ -60,7 +65,7 @@ export const TypebotViewer = ({
className="flex text-base overflow-hidden bg-cover h-screen w-screen flex-col items-center typebot-container"
style={{
// We set this as inline style to avoid color flash for SSR
backgroundColor: containerBgColor,
backgroundColor: containerBgColor ?? 'transparent',
}}
data-testid="container"
>

View File

@ -1,25 +1,142 @@
import { BackgroundType, Theme } from 'models'
import {
Background,
BackgroundType,
ChatTheme,
ContainerColors,
GeneralTheme,
InputColors,
Theme,
} from 'models'
const cssVariableNames = {
container: {
bg: {
image: '--typebot-container-bg-image',
color: '--typebot-container-bg-color',
},
general: {
bgImage: '--typebot-container-bg-image',
bgColor: '--typebot-container-bg-color',
fontFamily: '--typebot-container-font-family',
},
chat: {
hostBubbles: {
bgColor: '--typebot-host-bubble-bg-color',
color: '--typebot-host-bubble-color',
},
guestBubbles: {
bgColor: '--typebot-guest-bubble-bg-color',
color: '--typebot-guest-bubble-color',
},
inputs: {
bgColor: '--typebot-input-bg-color',
color: '--typebot-input-color',
placeholderColor: '--typebot-input-placeholder-color',
},
buttons: {
bgColor: '--typebot-button-bg-color',
color: '--typebot-button-color',
},
},
}
export const setCssVariablesValue = (
theme: Theme,
theme: Theme | undefined,
documentStyle: CSSStyleDeclaration
) => {
const { background, font } = theme.general
documentStyle.setProperty(
background.type === BackgroundType.IMAGE
? cssVariableNames.container.bg.image
: cssVariableNames.container.bg.color,
background.type === BackgroundType.NONE ? 'transparent' : background.content
)
documentStyle.setProperty(cssVariableNames.container.fontFamily, font)
if (!theme) return
if (theme.general) setGeneralTheme(theme.general, documentStyle)
if (theme.chat) setChatTheme(theme.chat, documentStyle)
}
const setGeneralTheme = (
generalTheme: GeneralTheme,
documentStyle: CSSStyleDeclaration
) => {
const { background, font } = generalTheme
if (background) setTypebotBackground
if (font) documentStyle.setProperty(cssVariableNames.general.fontFamily, font)
}
const setChatTheme = (
chatTheme: ChatTheme,
documentStyle: CSSStyleDeclaration
) => {
const { hostBubbles, guestBubbles, buttons, inputs } = chatTheme
if (hostBubbles) setHostBubbles(hostBubbles, documentStyle)
if (guestBubbles) setGuestBubbles(guestBubbles, documentStyle)
if (buttons) setButtons(buttons, documentStyle)
if (inputs) setInputs(inputs, documentStyle)
}
const setHostBubbles = (
hostBubbles: ContainerColors,
documentStyle: CSSStyleDeclaration
) => {
if (hostBubbles.backgroundColor)
documentStyle.setProperty(
cssVariableNames.chat.hostBubbles.bgColor,
hostBubbles.backgroundColor
)
if (hostBubbles.color)
documentStyle.setProperty(
cssVariableNames.chat.hostBubbles.color,
hostBubbles.color
)
}
const setGuestBubbles = (
guestBubbles: ContainerColors,
documentStyle: CSSStyleDeclaration
) => {
if (guestBubbles.backgroundColor)
documentStyle.setProperty(
cssVariableNames.chat.guestBubbles.bgColor,
guestBubbles.backgroundColor
)
if (guestBubbles.color)
documentStyle.setProperty(
cssVariableNames.chat.guestBubbles.color,
guestBubbles.color
)
}
const setButtons = (
buttons: ContainerColors,
documentStyle: CSSStyleDeclaration
) => {
if (buttons.backgroundColor)
documentStyle.setProperty(
cssVariableNames.chat.buttons.bgColor,
buttons.backgroundColor
)
if (buttons.color)
documentStyle.setProperty(
cssVariableNames.chat.buttons.color,
buttons.color
)
}
const setInputs = (inputs: InputColors, documentStyle: CSSStyleDeclaration) => {
if (inputs.backgroundColor)
documentStyle.setProperty(
cssVariableNames.chat.inputs.bgColor,
inputs.backgroundColor
)
if (inputs.color)
documentStyle.setProperty(cssVariableNames.chat.inputs.color, inputs.color)
if (inputs.placeholderColor)
documentStyle.setProperty(
cssVariableNames.chat.inputs.placeholderColor,
inputs.placeholderColor
)
}
const setTypebotBackground = (
background: Background,
documentStyle: CSSStyleDeclaration
) => {
documentStyle.setProperty(
background?.type === BackgroundType.IMAGE
? cssVariableNames.general.bgImage
: cssVariableNames.general.bgColor,
background.type === BackgroundType.NONE
? 'transparent'
: background.content ?? '#ffffff'
)
}

View File

@ -18,6 +18,6 @@ export type PublicTypebot = Omit<
choiceItems: Table<ChoiceItem>
variables: Table<Variable>
edges: Table<Edge>
theme: Theme
settings: Settings
theme?: Theme
settings?: Settings
}

View File

@ -1,9 +1,9 @@
export type Settings = {
typingEmulation: TypingEmulationSettings
typingEmulation?: TypingEmulationSettings
}
export type TypingEmulationSettings = {
enabled: boolean
speed: number
maxDelay: number
enabled?: boolean
speed?: number
maxDelay?: number
}

View File

@ -1,8 +1,27 @@
export type Theme = {
general: {
font: string
background: Background
general?: GeneralTheme
chat?: ChatTheme
}
export type GeneralTheme = {
font?: string
background?: Background
}
export type ChatTheme = {
hostBubbles?: ContainerColors
guestBubbles?: ContainerColors
buttons?: ContainerColors
inputs?: InputColors
}
export type ContainerColors = {
backgroundColor?: string
color?: string
}
export type InputColors = ContainerColors & {
placeholderColor?: string
}
export enum BackgroundType {
@ -13,5 +32,5 @@ export enum BackgroundType {
export type Background = {
type: BackgroundType
content: string
content?: string
}

View File

@ -23,8 +23,8 @@ export type Typebot = Omit<
variables: Table<Variable>
edges: Table<Edge>
webhooks: Table<Webhook>
theme: Theme
settings: Settings
theme?: Theme
settings?: Settings
}
export type Block = {