Skip to content

Commit

Permalink
feat(theme): ✨ Custom avatars
Browse files Browse the repository at this point in the history
  • Loading branch information
baptisteArno committed Feb 16, 2022
1 parent 1d3917f commit d2ac13b
Show file tree
Hide file tree
Showing 14 changed files with 294 additions and 81 deletions.
38 changes: 38 additions & 0 deletions apps/builder/assets/DefaultAvatar.tsx
Original file line number Diff line number Diff line change
@@ -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>
)
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { ChangeEvent, useEffect, useState } from 'react'
import { Button, Flex, HStack, Input, Stack, Text } from '@chakra-ui/react'
import { useEffect, useState } from 'react'
import { Button, Flex, HStack, Stack, Text } from '@chakra-ui/react'
import { SearchContextManager } from '@giphy/react-components'
import { UploadButton } from '../buttons/UploadButton'
import { GiphySearch } from './GiphySearch'
import { useTypebot } from 'contexts/TypebotContext'
import { useDebounce } from 'use-debounce'
import { InputWithVariableButton } from '../TextboxWithVariableButton'

type Props = {
url?: string
Expand Down Expand Up @@ -102,16 +103,12 @@ const EmbedLinkContent = ({ initialUrl, onNewUrl }: ContentProps) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedImageUrl])

const handleImageUrlChange = (e: ChangeEvent<HTMLInputElement>) =>
setImageUrl(e.target.value)

return (
<Stack py="2">
<Input
my="2"
<InputWithVariableButton
placeholder={'Paste the image link...'}
onChange={handleImageUrlChange}
value={imageUrl}
onChange={setImageUrl}
initialValue={imageUrl}
/>
</Stack>
)
Expand Down
82 changes: 82 additions & 0 deletions apps/builder/components/theme/ChatSettings/AvatarForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
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/shared/ImageUploadContent'
import { DefaultAvatar } from 'assets/DefaultAvatar'

type Props = {
title: string
avatarProps?: AvatarProps
isDefaultCheck?: boolean
onAvatarChange: (avatarProps: AvatarProps) => void
}

export const AvatarForm = ({
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 })
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>
{avatarProps?.url ? (
<Image
src={avatarProps.url}
alt="Website image"
cursor="pointer"
_hover={{ filter: 'brightness(.9)' }}
transition="filter 200ms"
rounded="full"
boxSize="40px"
objectFit="cover"
/>
) : (
<Box>
<DefaultAvatar
cursor="pointer"
_hover={{ filter: 'brightness(.9)' }}
/>
</Box>
)}
</PopoverTrigger>
<Portal>
<PopoverContent p="4">
<ImageUploadContent
url={avatarProps?.url}
onSubmit={handleImageUrl}
/>
</PopoverContent>
</Portal>
</Popover>
)}
</Flex>
</Stack>
)
}
19 changes: 18 additions & 1 deletion apps/builder/components/theme/ChatSettings/ChatThemeSettings.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Heading, Stack } from '@chakra-ui/react'
import { ChatTheme, ContainerColors, InputColors } from 'models'
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'
Expand All @@ -21,8 +22,24 @@ export const ChatThemeSettings = ({ chatTheme, onChatThemeChange }: Props) => {
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
title="Bot avatar"
avatarProps={chatTheme.hostAvatar}
isDefaultCheck
onAvatarChange={handleHostAvatarChange}
/>
<AvatarForm
title="User avatar"
avatarProps={chatTheme.guestAvatar}
onAvatarChange={handleGuestAvatarChange}
/>
<Stack borderWidth={1} rounded="md" p="4" spacing={4}>
<Heading fontSize="lg">Bot bubbles</Heading>
<HostBubbles
Expand Down
41 changes: 39 additions & 2 deletions apps/builder/playwright/tests/theme.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import { generate } from 'short-uuid'
import { importTypebotInDatabase } from '../services/database'
import { typebotViewer } from '../services/selectorUtils'

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 }) => {
Expand Down Expand Up @@ -40,7 +45,7 @@ test.describe.parallel('Theme page', () => {
})

test.describe('Chat', () => {
test('should reflect change in real-time', async ({ page }) => {
test.only('should reflect change in real-time', async ({ page }) => {
const typebotId = 'chat-theme-typebot'
try {
await importTypebotInDatabase(
Expand All @@ -53,7 +58,23 @@ test.describe.parallel('Theme page', () => {

await page.goto(`/typebots/${typebotId}/theme`)
await page.click('button:has-text("Chat")')
await page.waitForTimeout(300)

// 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 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(
Expand Down Expand Up @@ -105,6 +126,22 @@ test.describe.parallel('Theme page', () => {
)
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"]')
).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
)
await expect(typebotViewer(page).locator('img')).toHaveAttribute(
'src',
guestAvatarUrl
)

// Input
await page.click(
'[data-testid="inputs-theme"] >> [aria-label="Pick a color"] >> nth=0'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import React, { useEffect, useState } from 'react'
import { useTypebot } from '../../contexts/TypebotContext'
import { HostAvatar } from '../avatars/HostAvatar'
import { Avatar } from '../avatars/Avatar'
import { useFrame } from 'react-frame-component'
import { CSSTransition, TransitionGroup } from 'react-transition-group'
import { useHostAvatars } from '../../contexts/HostAvatarsContext'

export const AvatarSideContainer = () => {
export const AvatarSideContainer = ({
hostAvatarSrc,
}: {
hostAvatarSrc: string
}) => {
const { lastBubblesTopOffset } = useHostAvatars()
const { typebot } = useTypebot()
const { window, document } = useFrame()
const [marginBottom, setMarginBottom] = useState(
window.innerWidth < 400 ? 38 : 48
Expand Down Expand Up @@ -44,7 +46,7 @@ export const AvatarSideContainer = () => {
transition: 'top 350ms ease-out',
}}
>
<HostAvatar typebotName={typebot.name} />
<Avatar avatarSrc={hostAvatarSrc} />
</div>
</CSSTransition>
))}
Expand Down
9 changes: 8 additions & 1 deletion packages/bot-engine/src/components/ChatBlock/ChatBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
import { executeLogic } from 'services/logic'
import { executeIntegration } from 'services/integration'
import { parseRetryStep, stepCanBeRetried } from 'services/inputs'
import { parseVariables } from 'index'

type ChatBlockProps = {
steps: PublicStep[]
Expand Down Expand Up @@ -108,7 +109,13 @@ export const ChatBlock = ({
return (
<div className="flex">
<HostAvatarsContext>
<AvatarSideContainer />
{(typebot.theme.chat.hostAvatar?.isEnabled ?? true) && (
<AvatarSideContainer
hostAvatarSrc={parseVariables(typebot.variables)(
typebot.theme.chat.hostAvatar?.url
)}
/>
)}
<div className="flex flex-col w-full">
<TransitionGroup>
{displayedSteps
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { DateForm } from './inputs/DateForm'
import { ChoiceForm } from './inputs/ChoiceForm'
import { HostBubble } from './bubbles/HostBubble'
import { isInputValid } from 'services/inputs'
import { useTypebot } from 'contexts/TypebotContext'
import { parseVariables } from 'index'

export const ChatStep = ({
step,
Expand Down Expand Up @@ -38,6 +40,7 @@ const InputChatStep = ({
step: InputStep
onSubmit: (value: string, isRetry: boolean) => void
}) => {
const { typebot } = useTypebot()
const { addNewAvatarOffset } = useHostAvatars()
const [answer, setAnswer] = useState<string>()

Expand All @@ -52,7 +55,15 @@ const InputChatStep = ({
}

if (answer) {
return <GuestBubble message={answer} />
return (
<GuestBubble
message={answer}
showAvatar={typebot.theme.chat.guestAvatar?.isEnabled ?? false}
avatarSrc={parseVariables(typebot.variables)(
typebot.theme.chat.guestAvatar?.url
)}
/>
)
}
switch (step.type) {
case InputStepType.TEXT:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import { Avatar } from 'components/avatars/Avatar'
import React from 'react'
import { CSSTransition } from 'react-transition-group'

interface Props {
message: string
showAvatar: boolean
avatarSrc: string
}

export const GuestBubble = ({ message }: Props): JSX.Element => {
export const GuestBubble = ({
message,
showAvatar,
avatarSrc,
}: Props): JSX.Element => {
return (
<CSSTransition classNames="bubble" timeout={1000}>
<div className="flex justify-end mb-2 items-center">
Expand All @@ -16,6 +23,7 @@ export const GuestBubble = ({ message }: Props): JSX.Element => {
>
{message}
</div>
{showAvatar && <Avatar avatarSrc={avatarSrc} />}
</div>
</div>
</CSSTransition>
Expand Down
24 changes: 24 additions & 0 deletions packages/bot-engine/src/components/avatars/Avatar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import React from 'react'
import { DefaultAvatar } from './DefaultAvatar'

export const Avatar = ({ avatarSrc }: { avatarSrc: string }): JSX.Element => {
return (
<div className="w-full h-full rounded-full text-2xl md:text-4xl text-center xs:w-10 xs:h-10">
{avatarSrc !== '' ? (
<figure
className={
'flex justify-center items-center rounded-full text-white w-6 h-6 text-sm relative xs:w-full xs:h-full xs:text-xl'
}
>
<img
src={avatarSrc}
alt="Bot avatar"
className="rounded-full object-cover w-full h-full"
/>
</figure>
) : (
<DefaultAvatar />
)}
</div>
)
}
Loading

4 comments on commit d2ac13b

@vercel
Copy link

@vercel vercel bot commented on d2ac13b Feb 16, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@vercel
Copy link

@vercel vercel bot commented on d2ac13b Feb 16, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

docs – ./apps/docs

docs.typebot.io
docs-typebot-io.vercel.app
docs-git-main-typebot-io.vercel.app

@vercel
Copy link

@vercel vercel bot commented on d2ac13b Feb 16, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

builder-v2 – ./apps/builder

app.typebot.io
builder-v2-git-main-typebot-io.vercel.app
builder-v2-typebot-io.vercel.app

@vercel
Copy link

@vercel vercel bot commented on d2ac13b Feb 16, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

viewer-v2 – ./apps/viewer

typebot-viewer.vercel.app
viewer-v2-typebot-io.vercel.app
viewer-v2-git-main-typebot-io.vercel.app

Please sign in to comment.