2
0

(theme) Custom font option (#1268)

Closes #1249

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
- Introduced components for customizing fonts, including Google and
custom font options.
- Enhanced theme customization by simplifying theme objects and adding
new font customization options.
- Implemented dynamic font injection for web pages based on
user-selected font configurations.

- **Bug Fixes**
- Fixed a condition in theme template card rendering to correctly check
avatar enablement.
- Corrected font property handling across various components to support
both string and object types.

- **Refactor**
	- Updated option properties in RadioButtons component to be readonly.
- Simplified logic for setting CSS variables for fonts, including checks
for font types and existence.

- **Documentation**
- Added constants and schemas for supporting new font types in theme
customization.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Baptiste Arnaud
2024-02-20 10:36:57 +01:00
committed by GitHub
parent 927feae32b
commit 7cf1a3e26d
19 changed files with 341 additions and 158 deletions

View File

@ -10,7 +10,7 @@ import {
import { ReactNode } from 'react' import { ReactNode } from 'react'
type Props<T extends string> = { type Props<T extends string> = {
options: (T | { value: T; label: ReactNode })[] options: readonly (T | { value: T; label: ReactNode })[]
value?: T value?: T
defaultValue?: T defaultValue?: T
direction?: 'row' | 'column' direction?: 'row' | 'column'

View File

@ -213,7 +213,7 @@ const AvatarPreview = ({
avatar: NonNullable<Theme['chat']>['hostAvatar'] avatar: NonNullable<Theme['chat']>['hostAvatar']
}) => { }) => {
const { t } = useTranslate() const { t } = useTranslate()
if (avatar?.isEnabled) return null if (!avatar?.isEnabled) return null
return avatar?.url ? ( return avatar?.url ? (
<Image <Image
src={avatar.url} src={avatar.url}

View File

@ -0,0 +1,31 @@
import { TextInput } from '@/components/inputs'
import { Stack } from '@chakra-ui/react'
import { CustomFont } from '@typebot.io/schemas'
type Props = {
font: CustomFont
onFontChange: (font: CustomFont) => void
}
export const CustomFontForm = ({ font, onFontChange }: Props) => {
const updateFamily = (family: string) => onFontChange({ ...font, family })
const updateUrl = (url: string) => onFontChange({ ...font, url })
return (
<Stack>
<TextInput
direction="row"
label="Family:"
defaultValue={font.family}
onChange={updateFamily}
withVariableButton={false}
/>
<TextInput
direction="row"
label="URL:"
defaultValue={font.url}
onChange={updateUrl}
withVariableButton={false}
/>
</Stack>
)
}

View File

@ -0,0 +1,15 @@
import { Font } from '@typebot.io/schemas'
import { GoogleFontForm } from './GoogleFontForm'
import { CustomFontForm } from './CustomFontForm'
type Props = {
font: Font
onFontChange: (font: Font) => void
}
export const FontForm = ({ font, onFontChange }: Props) => {
if (typeof font === 'string' || font.type === 'Google')
return <GoogleFontForm font={font} onFontChange={onFontChange} />
if (font.type === 'Custom')
return <CustomFontForm font={font} onFontChange={onFontChange} />
}

View File

@ -1,51 +0,0 @@
import React, { useEffect, useState } from 'react'
import { Text, HStack } from '@chakra-ui/react'
import { AutocompleteInput } from '@/components/inputs/AutocompleteInput'
import { env } from '@typebot.io/env'
import { useTranslate } from '@tolgee/react'
type FontSelectorProps = {
activeFont?: string
onSelectFont: (font: string) => void
}
export const FontSelector = ({
activeFont,
onSelectFont,
}: FontSelectorProps) => {
const { t } = useTranslate()
const [currentFont, setCurrentFont] = useState(activeFont)
const [googleFonts, setGoogleFonts] = useState<string[]>([])
useEffect(() => {
fetchPopularFonts().then(setGoogleFonts)
}, [])
const fetchPopularFonts = async () => {
if (!env.NEXT_PUBLIC_GOOGLE_API_KEY) return []
const response = await fetch(
`https://www.googleapis.com/webfonts/v1/webfonts?key=${env.NEXT_PUBLIC_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>{t('theme.sideMenu.global.font')}</Text>
<AutocompleteInput
value={activeFont}
items={googleFonts}
onChange={handleFontSelected}
withVariableButton={false}
/>
</HStack>
)
}

View File

@ -1,18 +1,29 @@
import { Flex, FormLabel, Stack, Switch, useDisclosure } from '@chakra-ui/react' import {
import { Background, Theme } from '@typebot.io/schemas' Flex,
FormLabel,
Stack,
Switch,
useDisclosure,
Text,
} from '@chakra-ui/react'
import { Background, Font, Theme } from '@typebot.io/schemas'
import React from 'react' import React from 'react'
import { BackgroundSelector } from './BackgroundSelector' import { BackgroundSelector } from './BackgroundSelector'
import { FontSelector } from './FontSelector'
import { LockTag } from '@/features/billing/components/LockTag' import { LockTag } from '@/features/billing/components/LockTag'
import { Plan } from '@typebot.io/prisma' import { Plan } from '@typebot.io/prisma'
import { isFreePlan } from '@/features/billing/helpers/isFreePlan' import { isFreePlan } from '@/features/billing/helpers/isFreePlan'
import { useWorkspace } from '@/features/workspace/WorkspaceProvider' import { useWorkspace } from '@/features/workspace/WorkspaceProvider'
import { ChangePlanModal } from '@/features/billing/components/ChangePlanModal' import { ChangePlanModal } from '@/features/billing/components/ChangePlanModal'
import { useTranslate } from '@tolgee/react' import { useTranslate } from '@tolgee/react'
import { defaultTheme } from '@typebot.io/schemas/features/typebot/theme/constants' import {
defaultTheme,
fontTypes,
} from '@typebot.io/schemas/features/typebot/theme/constants'
import { trpc } from '@/lib/trpc' import { trpc } from '@/lib/trpc'
import { env } from '@typebot.io/env' import { env } from '@typebot.io/env'
import { useTypebot } from '@/features/editor/providers/TypebotProvider' import { useTypebot } from '@/features/editor/providers/TypebotProvider'
import { RadioButtons } from '@/components/inputs/RadioButtons'
import { FontForm } from './FontForm'
type Props = { type Props = {
isBrandingEnabled: boolean isBrandingEnabled: boolean
@ -36,9 +47,19 @@ export const GeneralSettings = ({
const { mutate: trackClientEvents } = const { mutate: trackClientEvents } =
trpc.telemetry.trackClientEvents.useMutation() trpc.telemetry.trackClientEvents.useMutation()
const handleSelectFont = (font: string) => const updateFont = (font: Font) =>
onGeneralThemeChange({ ...generalTheme, font }) onGeneralThemeChange({ ...generalTheme, font })
const updateFontType = (type: (typeof fontTypes)[number]) => {
onGeneralThemeChange({
...generalTheme,
font:
typeof generalTheme?.font === 'string'
? { type }
: { ...generalTheme?.font, type },
})
}
const handleBackgroundChange = (background: Background) => const handleBackgroundChange = (background: Background) =>
onGeneralThemeChange({ ...generalTheme, background }) onGeneralThemeChange({ ...generalTheme, background })
@ -63,6 +84,11 @@ export const GeneralSettings = ({
onBrandingChange(!isBrandingEnabled) onBrandingChange(!isBrandingEnabled)
} }
const fontType =
(typeof generalTheme?.font === 'string'
? 'Google'
: generalTheme?.font?.type) ?? defaultTheme.general.font.type
return ( return (
<Stack spacing={6}> <Stack spacing={6}>
<ChangePlanModal <ChangePlanModal
@ -85,10 +111,18 @@ export const GeneralSettings = ({
onChange={updateBranding} onChange={updateBranding}
/> />
</Flex> </Flex>
<FontSelector <Stack>
activeFont={generalTheme?.font ?? defaultTheme.general.font} <Text>{t('theme.sideMenu.global.font')}</Text>
onSelectFont={handleSelectFont} <RadioButtons
/> options={fontTypes}
defaultValue={fontType}
onSelect={updateFontType}
/>
<FontForm
font={generalTheme?.font ?? defaultTheme.general.font}
onFontChange={updateFont}
/>
</Stack>
<BackgroundSelector <BackgroundSelector
background={generalTheme?.background ?? defaultTheme.general.background} background={generalTheme?.background ?? defaultTheme.general.background}
onBackgroundChange={handleBackgroundChange} onBackgroundChange={handleBackgroundChange}

View File

@ -0,0 +1,49 @@
import { Select } from '@/components/inputs/Select'
import { env } from '@typebot.io/env'
import { GoogleFont } from '@typebot.io/schemas'
import { useState, useEffect } from 'react'
type Props = {
font: GoogleFont | string
onFontChange: (font: GoogleFont) => void
}
export const GoogleFontForm = ({ font, onFontChange }: Props) => {
const [currentFont, setCurrentFont] = useState(
typeof font === 'string' ? font : font.family
)
const [googleFonts, setGoogleFonts] = useState<string[]>([])
useEffect(() => {
fetchPopularFonts().then(setGoogleFonts)
}, [])
const fetchPopularFonts = async () => {
if (!env.NEXT_PUBLIC_GOOGLE_API_KEY) return []
try {
const response = await fetch(
`https://www.googleapis.com/webfonts/v1/webfonts?key=${env.NEXT_PUBLIC_GOOGLE_API_KEY}&sort=popularity`
)
return (await response.json()).items.map(
(item: { family: string }) => item.family
)
} catch (error) {
console.error('Failed to fetch Google Fonts:', error)
return []
}
}
const handleFontSelected = (nextFont: string | undefined) => {
if (nextFont === currentFont || !nextFont) return
setCurrentFont(nextFont)
onFontChange({ type: 'Google', family: nextFont })
}
return (
<Select
selectedItem={currentFont}
items={googleFonts}
onSelect={handleFontSelected}
/>
)
}

View File

@ -15,25 +15,7 @@ export const galleryTemplates: Pick<ThemeTemplate, 'id' | 'name' | 'theme'>[] =
{ {
id: 'typebot-light', id: 'typebot-light',
name: 'Typebot Light', name: 'Typebot Light',
theme: { 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', id: 'typebot-dark',
@ -45,15 +27,9 @@ export const galleryTemplates: Pick<ThemeTemplate, 'id' | 'name' | 'theme'>[] =
backgroundColor: '#1e293b', backgroundColor: '#1e293b',
placeholderColor: '#9095A0', placeholderColor: '#9095A0',
}, },
buttons: { color: '#ffffff', backgroundColor: '#1a5fff' },
hostAvatar: {
isEnabled: true,
},
hostBubbles: { color: '#ffffff', backgroundColor: '#1e293b' }, hostBubbles: { color: '#ffffff', backgroundColor: '#1e293b' },
guestBubbles: { color: '#FFFFFF', backgroundColor: '#FF8E21' },
}, },
general: { general: {
font: 'Open Sans',
background: { type: BackgroundType.COLOR, content: '#171923' }, background: { type: BackgroundType.COLOR, content: '#171923' },
}, },
}, },
@ -63,19 +39,15 @@ export const galleryTemplates: Pick<ThemeTemplate, 'id' | 'name' | 'theme'>[] =
name: 'Minimalist Black', name: 'Minimalist Black',
theme: { theme: {
chat: { chat: {
inputs: { buttons: { backgroundColor: '#303235' },
color: '#303235',
backgroundColor: '#FFFFFF',
placeholderColor: '#9095A0',
},
buttons: { color: '#FFFFFF', backgroundColor: '#303235' },
hostAvatar: { isEnabled: false }, hostAvatar: { isEnabled: false },
hostBubbles: { color: '#303235', backgroundColor: '#F7F8FF' },
guestBubbles: { color: '#303235', backgroundColor: '#F7F8FF' }, guestBubbles: { color: '#303235', backgroundColor: '#F7F8FF' },
}, },
general: { general: {
font: 'Inter', font: {
background: { type: BackgroundType.COLOR, content: '#ffffff' }, type: 'Google',
family: 'Inter',
},
}, },
}, },
}, },
@ -84,19 +56,15 @@ export const galleryTemplates: Pick<ThemeTemplate, 'id' | 'name' | 'theme'>[] =
name: 'Minimalist Teal', name: 'Minimalist Teal',
theme: { theme: {
chat: { chat: {
inputs: { buttons: { backgroundColor: '#0d9488' },
color: '#303235',
backgroundColor: '#FFFFFF',
placeholderColor: '#9095A0',
},
buttons: { color: '#FFFFFF', backgroundColor: '#0d9488' },
hostAvatar: { isEnabled: false }, hostAvatar: { isEnabled: false },
hostBubbles: { color: '#303235', backgroundColor: '#F7F8FF' },
guestBubbles: { color: '#303235', backgroundColor: '#F7F8FF' }, guestBubbles: { color: '#303235', backgroundColor: '#F7F8FF' },
}, },
general: { general: {
font: 'Inter', font: {
background: { type: BackgroundType.COLOR, content: '#ffffff' }, type: 'Google',
family: 'Inter',
},
}, },
}, },
}, },
@ -106,18 +74,14 @@ export const galleryTemplates: Pick<ThemeTemplate, 'id' | 'name' | 'theme'>[] =
name: 'Bright Rain', name: 'Bright Rain',
theme: { theme: {
chat: { chat: {
inputs: { buttons: { backgroundColor: '#D27A7D' },
color: '#303235',
backgroundColor: '#FFFFFF',
placeholderColor: '#9095A0',
},
buttons: { color: '#fff', backgroundColor: '#D27A7D' },
hostAvatar: { isEnabled: true },
hostBubbles: { color: '#303235', backgroundColor: '#F7F8FF' },
guestBubbles: { color: '#303235', backgroundColor: '#FDDDBF' }, guestBubbles: { color: '#303235', backgroundColor: '#FDDDBF' },
}, },
general: { general: {
font: 'Montserrat', font: {
type: 'Google',
family: 'Montserrat',
},
background: { background: {
type: BackgroundType.IMAGE, type: BackgroundType.IMAGE,
content: getOrigin() + '/images/backgrounds/brightRain.jpeg', content: getOrigin() + '/images/backgrounds/brightRain.jpeg',
@ -130,18 +94,14 @@ export const galleryTemplates: Pick<ThemeTemplate, 'id' | 'name' | 'theme'>[] =
name: 'Ray of Lights', name: 'Ray of Lights',
theme: { theme: {
chat: { chat: {
inputs: { buttons: { backgroundColor: '#1A2249' },
color: '#303235', guestBubbles: { backgroundColor: '#1A2249' },
backgroundColor: '#FFFFFF',
placeholderColor: '#9095A0',
},
buttons: { color: '#fff', backgroundColor: '#1A2249' },
hostAvatar: { isEnabled: true },
hostBubbles: { color: '#303235', backgroundColor: '#F7F8FF' },
guestBubbles: { color: '#fff', backgroundColor: '#1A2249' },
}, },
general: { general: {
font: 'Raleway', font: {
type: 'Google',
family: 'Raleway',
},
background: { background: {
type: BackgroundType.IMAGE, type: BackgroundType.IMAGE,
content: getOrigin() + '/images/backgrounds/rayOfLights.jpeg', content: getOrigin() + '/images/backgrounds/rayOfLights.jpeg',

View File

@ -19398,7 +19398,52 @@
"type": "object", "type": "object",
"properties": { "properties": {
"font": { "font": {
"type": "string" "anyOf": [
{
"type": "string"
},
{
"oneOf": [
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": [
"Google"
]
},
"family": {
"type": "string"
}
},
"required": [
"type"
]
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": [
"Custom"
]
},
"family": {
"type": "string"
},
"url": {
"type": "string"
}
},
"required": [
"type"
]
}
]
}
]
}, },
"background": { "background": {
"type": "object", "type": "object",

View File

@ -6697,7 +6697,52 @@
"type": "object", "type": "object",
"properties": { "properties": {
"font": { "font": {
"type": "string" "anyOf": [
{
"type": "string"
},
{
"oneOf": [
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": [
"Google"
]
},
"family": {
"type": "string"
}
},
"required": [
"type"
]
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": [
"Custom"
]
},
"family": {
"type": "string"
},
"url": {
"type": "string"
}
},
"required": [
"type"
]
}
]
}
]
}, },
"background": { "background": {
"type": "object", "type": "object",

View File

@ -4,17 +4,20 @@ import { SEO } from './Seo'
import { Typebot } from '@typebot.io/schemas/features/typebot/typebot' import { Typebot } from '@typebot.io/schemas/features/typebot/typebot'
import { BackgroundType } from '@typebot.io/schemas/features/typebot/theme/constants' import { BackgroundType } from '@typebot.io/schemas/features/typebot/theme/constants'
import { defaultSettings } from '@typebot.io/schemas/features/typebot/settings/constants' import { defaultSettings } from '@typebot.io/schemas/features/typebot/settings/constants'
import { Font } from '@typebot.io/schemas'
export type TypebotV3PageProps = { export type TypebotV3PageProps = {
url: string url: string
name: string name: string
publicId: string | null publicId: string | null
font: Font | null
isHideQueryParamsEnabled: boolean | null isHideQueryParamsEnabled: boolean | null
background: NonNullable<Typebot['theme']['general']>['background'] background: NonNullable<Typebot['theme']['general']>['background']
metadata: Typebot['settings']['metadata'] metadata: Typebot['settings']['metadata']
} }
export const TypebotPageV3 = ({ export const TypebotPageV3 = ({
font,
publicId, publicId,
name, name,
url, url,
@ -51,7 +54,11 @@ export const TypebotPageV3 = ({
}} }}
> >
<SEO url={url} typebotName={name} metadata={metadata} /> <SEO url={url} typebotName={name} metadata={metadata} />
<Standard typebot={publicId} onInit={clearQueryParamsIfNecessary} /> <Standard
typebot={publicId}
onInit={clearQueryParamsIfNecessary}
font={font ?? undefined}
/>
</div> </div>
) )
} }

View File

@ -116,6 +116,7 @@ const getTypebotFromPublicId = async (publicId?: string) => {
publishedTypebot.settings.general?.isHideQueryParamsEnabled ?? publishedTypebot.settings.general?.isHideQueryParamsEnabled ??
defaultSettings.general.isHideQueryParamsEnabled, defaultSettings.general.isHideQueryParamsEnabled,
metadata: publishedTypebot.settings.metadata ?? {}, metadata: publishedTypebot.settings.metadata ?? {},
font: publishedTypebot.theme.general?.font ?? null,
} satisfies Pick< } satisfies Pick<
TypebotV3PageProps, TypebotV3PageProps,
| 'name' | 'name'
@ -123,6 +124,7 @@ const getTypebotFromPublicId = async (publicId?: string) => {
| 'background' | 'background'
| 'isHideQueryParamsEnabled' | 'isHideQueryParamsEnabled'
| 'metadata' | 'metadata'
| 'font'
>) >)
: publishedTypebot : publishedTypebot
} }
@ -161,6 +163,7 @@ const getTypebotFromCustomDomain = async (customDomain: string) => {
publishedTypebot.settings.general?.isHideQueryParamsEnabled ?? publishedTypebot.settings.general?.isHideQueryParamsEnabled ??
defaultSettings.general.isHideQueryParamsEnabled, defaultSettings.general.isHideQueryParamsEnabled,
metadata: publishedTypebot.settings.metadata ?? {}, metadata: publishedTypebot.settings.metadata ?? {},
font: publishedTypebot.theme.general?.font ?? null,
} satisfies Pick< } satisfies Pick<
TypebotV3PageProps, TypebotV3PageProps,
| 'name' | 'name'
@ -168,6 +171,7 @@ const getTypebotFromCustomDomain = async (customDomain: string) => {
| 'background' | 'background'
| 'isHideQueryParamsEnabled' | 'isHideQueryParamsEnabled'
| 'metadata' | 'metadata'
| 'font'
>) >)
: publishedTypebot : publishedTypebot
} }
@ -196,6 +200,7 @@ const App = ({
| 'background' | 'background'
| 'isHideQueryParamsEnabled' | 'isHideQueryParamsEnabled'
| 'metadata' | 'metadata'
| 'font'
> >
incompatibleBrowser: string | null incompatibleBrowser: string | null
}) => { }) => {
@ -231,6 +236,7 @@ const App = ({
publishedTypebot.background ?? defaultTheme.general.background publishedTypebot.background ?? defaultTheme.general.background
} }
metadata={publishedTypebot.metadata ?? {}} metadata={publishedTypebot.metadata ?? {}}
font={publishedTypebot.font}
/> />
) )
} }

View File

@ -13,7 +13,6 @@ import {
} from '@typebot.io/schemas' } from '@typebot.io/schemas'
import { Log } from '@typebot.io/prisma' import { Log } from '@typebot.io/prisma'
import { LiteBadge } from './LiteBadge' import { LiteBadge } from './LiteBadge'
import { isNotEmpty } from '@typebot.io/lib'
import { BackgroundType } from '@typebot.io/schemas/features/typebot/theme/constants' import { BackgroundType } from '@typebot.io/schemas/features/typebot/theme/constants'
import { env } from '@typebot.io/env' import { env } from '@typebot.io/env'
@ -74,7 +73,7 @@ export const TypebotViewer = ({
</style> </style>
<style>{typebot.theme?.customCss}</style> <style>{typebot.theme?.customCss}</style>
<style>{importantStyles}</style> <style>{importantStyles}</style>
{isNotEmpty(typebot?.theme?.general?.font) && ( {typebot?.theme?.general?.font && (
<style <style
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: `@import url('https://fonts.googleapis.com/css2?family=${ __html: `@import url('https://fonts.googleapis.com/css2?family=${

View File

@ -50,7 +50,8 @@ const setGeneralTheme = (
) => { ) => {
const { background, font } = generalTheme const { background, font } = generalTheme
if (background) setTypebotBackground if (background) setTypebotBackground
if (font) documentStyle.setProperty(cssVariableNames.general.fontFamily, font) if (font && typeof font === 'string')
documentStyle.setProperty(cssVariableNames.general.fontFamily, font)
} }
const setChatTheme = ( const setChatTheme = (

View File

@ -12,11 +12,12 @@ import {
} from '@/utils/storage' } from '@/utils/storage'
import { setCssVariablesValue } from '@/utils/setCssVariablesValue' import { setCssVariablesValue } from '@/utils/setCssVariablesValue'
import immutableCss from '../assets/immutable.css' import immutableCss from '../assets/immutable.css'
import { InputBlock } from '@typebot.io/schemas' import { Font, InputBlock } from '@typebot.io/schemas'
import { StartFrom } from '@typebot.io/schemas' import { StartFrom } from '@typebot.io/schemas'
import { defaultTheme } from '@typebot.io/schemas/features/typebot/theme/constants' import { defaultTheme } from '@typebot.io/schemas/features/typebot/theme/constants'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { HTTPError } from 'ky' import { HTTPError } from 'ky'
import { injectFont } from '@/utils/injectFont'
export type BotProps = { export type BotProps = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -25,6 +26,7 @@ export type BotProps = {
resultId?: string resultId?: string
prefilledVariables?: Record<string, unknown> prefilledVariables?: Record<string, unknown>
apiHost?: string apiHost?: string
font?: Font
onNewInputBlock?: (inputBlock: InputBlock) => void onNewInputBlock?: (inputBlock: InputBlock) => void
onAnswer?: (answer: { message: string; blockId: string }) => void onAnswer?: (answer: { message: string; blockId: string }) => void
onInit?: () => void onInit?: () => void
@ -42,6 +44,7 @@ export const Bot = (props: BotProps & { class?: string }) => {
const [error, setError] = createSignal<Error | undefined>() const [error, setError] = createSignal<Error | undefined>()
const initializeBot = async () => { const initializeBot = async () => {
if (props.font) injectFont(props.font)
setIsInitialized(true) setIsInitialized(true)
const urlParams = new URLSearchParams(location.search) const urlParams = new URLSearchParams(location.search)
props.onInit?.() props.onInit?.()
@ -193,34 +196,16 @@ const BotContent = (props: BotContentProps) => {
return setIsMobile(entries[0].target.clientWidth < 400) return setIsMobile(entries[0].target.clientWidth < 400)
}) })
const injectCustomFont = () => {
const existingFont = document.getElementById('bot-font')
if (
existingFont
?.getAttribute('href')
?.includes(
props.initialChatReply.typebot?.theme?.general?.font ??
defaultTheme.general.font
)
)
return
const font = document.createElement('link')
font.href = `https://fonts.bunny.net/css2?family=${
props.initialChatReply.typebot?.theme?.general?.font ??
defaultTheme.general.font
}: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(() => { onMount(() => {
if (!botContainer) return if (!botContainer) return
resizeObserver.observe(botContainer) resizeObserver.observe(botContainer)
}) })
createEffect(() => { createEffect(() => {
injectCustomFont() injectFont(
props.initialChatReply.typebot.theme.general?.font ??
defaultTheme.general.font
)
if (!botContainer) return if (!botContainer) return
setCssVariablesValue(props.initialChatReply.typebot.theme, botContainer) setCssVariablesValue(props.initialChatReply.typebot.theme, botContainer)
}) })

View File

@ -0,0 +1,32 @@
import { isEmpty } from '@typebot.io/lib'
import { Font } from '@typebot.io/schemas'
import { defaultTheme } from '@typebot.io/schemas/features/typebot/theme/constants'
const googleFontCdnBaseUrl = 'https://fonts.bunny.net/css2'
export const injectFont = (font: Font) => {
const existingFont = document.getElementById('bot-font')
if (typeof font === 'string' || font.type === 'Google') {
const fontFamily =
(typeof font === 'string' ? font : font.family) ??
defaultTheme.general.font.family
if (existingFont?.getAttribute('href')?.includes(fontFamily)) return
const fontElement = document.createElement('link')
fontElement.href = `${googleFontCdnBaseUrl}?family=${fontFamily}:ital,wght@0,300;0,400;0,600;1,300;1,400;1,600&display=swap`
fontElement.rel = 'stylesheet'
fontElement.id = 'bot-font'
document.head.appendChild(fontElement)
return
}
if (font.type === 'Custom') {
if (existingFont?.getAttribute('href') === font.url || isEmpty(font.url))
return
const fontElement = document.createElement('link')
fontElement.href = font.url
fontElement.rel = 'stylesheet'
fontElement.id = 'bot-font'
document.head.appendChild(fontElement)
}
}

View File

@ -68,7 +68,9 @@ const setGeneralTheme = (
) )
documentStyle.setProperty( documentStyle.setProperty(
cssVariableNames.general.fontFamily, cssVariableNames.general.fontFamily,
generalTheme.font ?? defaultTheme.general.font (typeof generalTheme.font === 'string'
? generalTheme.font
: generalTheme.font?.family) ?? defaultTheme.general.font.family
) )
} }

View File

@ -6,6 +6,8 @@ export enum BackgroundType {
NONE = 'None', NONE = 'None',
} }
export const fontTypes = ['Google', 'Custom'] as const
export const defaultTheme = { export const defaultTheme = {
chat: { chat: {
roundness: 'medium', roundness: 'medium',
@ -25,7 +27,10 @@ export const defaultTheme = {
}, },
}, },
general: { general: {
font: 'Open Sans', font: {
type: 'Google',
family: 'Open Sans',
},
background: { type: BackgroundType.COLOR, content: '#ffffff' }, background: { type: BackgroundType.COLOR, content: '#ffffff' },
}, },
} as const satisfies Theme } as const satisfies Theme

View File

@ -1,6 +1,6 @@
import { ThemeTemplate as ThemeTemplatePrisma } from '@typebot.io/prisma' import { ThemeTemplate as ThemeTemplatePrisma } from '@typebot.io/prisma'
import { z } from '../../../zod' import { z } from '../../../zod'
import { BackgroundType } from './constants' import { BackgroundType, fontTypes } from './constants'
const avatarPropsSchema = z.object({ const avatarPropsSchema = z.object({
isEnabled: z.boolean().optional(), isEnabled: z.boolean().optional(),
@ -33,8 +33,26 @@ const backgroundSchema = z.object({
content: z.string().optional().optional(), content: z.string().optional().optional(),
}) })
const googleFontSchema = z.object({
type: z.literal(fontTypes[0]),
family: z.string().optional(),
})
export type GoogleFont = z.infer<typeof googleFontSchema>
const customFontSchema = z.object({
type: z.literal(fontTypes[1]),
family: z.string().optional(),
url: z.string().optional(),
})
export type CustomFont = z.infer<typeof customFontSchema>
export const fontSchema = z
.string()
.or(z.discriminatedUnion('type', [googleFontSchema, customFontSchema]))
export type Font = z.infer<typeof fontSchema>
const generalThemeSchema = z.object({ const generalThemeSchema = z.object({
font: z.string().optional(), font: fontSchema.optional(),
background: backgroundSchema.optional(), background: backgroundSchema.optional(),
}) })
@ -56,7 +74,7 @@ export const themeTemplateSchema = z.object({
workspaceId: z.string(), workspaceId: z.string(),
createdAt: z.date(), createdAt: z.date(),
updatedAt: z.date(), updatedAt: z.date(),
}) satisfies z.ZodType<ThemeTemplatePrisma> }) satisfies z.ZodType<Omit<ThemeTemplatePrisma, 'theme'>>
export type Theme = z.infer<typeof themeSchema> export type Theme = z.infer<typeof themeSchema>
export type ChatTheme = z.infer<typeof chatThemeSchema> export type ChatTheme = z.infer<typeof chatThemeSchema>