✨ (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:
@ -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'
|
||||||
|
@ -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}
|
||||||
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
@ -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} />
|
||||||
|
}
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
@ -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}
|
||||||
|
@ -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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
@ -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',
|
||||||
|
@ -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",
|
||||||
|
@ -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",
|
||||||
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -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=${
|
||||||
|
@ -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 = (
|
||||||
|
@ -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)
|
||||||
})
|
})
|
||||||
|
32
packages/embeds/js/src/utils/injectFont.ts
Normal file
32
packages/embeds/js/src/utils/injectFont.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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>
|
||||||
|
Reference in New Issue
Block a user