✨ (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'
|
||||
|
||||
type Props<T extends string> = {
|
||||
options: (T | { value: T; label: ReactNode })[]
|
||||
options: readonly (T | { value: T; label: ReactNode })[]
|
||||
value?: T
|
||||
defaultValue?: T
|
||||
direction?: 'row' | 'column'
|
||||
|
||||
@@ -213,7 +213,7 @@ const AvatarPreview = ({
|
||||
avatar: NonNullable<Theme['chat']>['hostAvatar']
|
||||
}) => {
|
||||
const { t } = useTranslate()
|
||||
if (avatar?.isEnabled) return null
|
||||
if (!avatar?.isEnabled) return null
|
||||
return avatar?.url ? (
|
||||
<Image
|
||||
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 { Background, Theme } from '@typebot.io/schemas'
|
||||
import {
|
||||
Flex,
|
||||
FormLabel,
|
||||
Stack,
|
||||
Switch,
|
||||
useDisclosure,
|
||||
Text,
|
||||
} from '@chakra-ui/react'
|
||||
import { Background, Font, Theme } from '@typebot.io/schemas'
|
||||
import React from 'react'
|
||||
import { BackgroundSelector } from './BackgroundSelector'
|
||||
import { FontSelector } from './FontSelector'
|
||||
import { LockTag } from '@/features/billing/components/LockTag'
|
||||
import { Plan } from '@typebot.io/prisma'
|
||||
import { isFreePlan } from '@/features/billing/helpers/isFreePlan'
|
||||
import { useWorkspace } from '@/features/workspace/WorkspaceProvider'
|
||||
import { ChangePlanModal } from '@/features/billing/components/ChangePlanModal'
|
||||
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 { env } from '@typebot.io/env'
|
||||
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
|
||||
import { RadioButtons } from '@/components/inputs/RadioButtons'
|
||||
import { FontForm } from './FontForm'
|
||||
|
||||
type Props = {
|
||||
isBrandingEnabled: boolean
|
||||
@@ -36,9 +47,19 @@ export const GeneralSettings = ({
|
||||
const { mutate: trackClientEvents } =
|
||||
trpc.telemetry.trackClientEvents.useMutation()
|
||||
|
||||
const handleSelectFont = (font: string) =>
|
||||
const updateFont = (font: 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) =>
|
||||
onGeneralThemeChange({ ...generalTheme, background })
|
||||
|
||||
@@ -63,6 +84,11 @@ export const GeneralSettings = ({
|
||||
onBrandingChange(!isBrandingEnabled)
|
||||
}
|
||||
|
||||
const fontType =
|
||||
(typeof generalTheme?.font === 'string'
|
||||
? 'Google'
|
||||
: generalTheme?.font?.type) ?? defaultTheme.general.font.type
|
||||
|
||||
return (
|
||||
<Stack spacing={6}>
|
||||
<ChangePlanModal
|
||||
@@ -85,10 +111,18 @@ export const GeneralSettings = ({
|
||||
onChange={updateBranding}
|
||||
/>
|
||||
</Flex>
|
||||
<FontSelector
|
||||
activeFont={generalTheme?.font ?? defaultTheme.general.font}
|
||||
onSelectFont={handleSelectFont}
|
||||
/>
|
||||
<Stack>
|
||||
<Text>{t('theme.sideMenu.global.font')}</Text>
|
||||
<RadioButtons
|
||||
options={fontTypes}
|
||||
defaultValue={fontType}
|
||||
onSelect={updateFontType}
|
||||
/>
|
||||
<FontForm
|
||||
font={generalTheme?.font ?? defaultTheme.general.font}
|
||||
onFontChange={updateFont}
|
||||
/>
|
||||
</Stack>
|
||||
<BackgroundSelector
|
||||
background={generalTheme?.background ?? defaultTheme.general.background}
|
||||
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',
|
||||
name: 'Typebot Light',
|
||||
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' },
|
||||
},
|
||||
},
|
||||
theme: {},
|
||||
},
|
||||
{
|
||||
id: 'typebot-dark',
|
||||
@@ -45,15 +27,9 @@ export const galleryTemplates: Pick<ThemeTemplate, 'id' | 'name' | 'theme'>[] =
|
||||
backgroundColor: '#1e293b',
|
||||
placeholderColor: '#9095A0',
|
||||
},
|
||||
buttons: { color: '#ffffff', backgroundColor: '#1a5fff' },
|
||||
hostAvatar: {
|
||||
isEnabled: true,
|
||||
},
|
||||
hostBubbles: { color: '#ffffff', backgroundColor: '#1e293b' },
|
||||
guestBubbles: { color: '#FFFFFF', backgroundColor: '#FF8E21' },
|
||||
},
|
||||
general: {
|
||||
font: 'Open Sans',
|
||||
background: { type: BackgroundType.COLOR, content: '#171923' },
|
||||
},
|
||||
},
|
||||
@@ -63,19 +39,15 @@ export const galleryTemplates: Pick<ThemeTemplate, 'id' | 'name' | 'theme'>[] =
|
||||
name: 'Minimalist Black',
|
||||
theme: {
|
||||
chat: {
|
||||
inputs: {
|
||||
color: '#303235',
|
||||
backgroundColor: '#FFFFFF',
|
||||
placeholderColor: '#9095A0',
|
||||
},
|
||||
buttons: { color: '#FFFFFF', backgroundColor: '#303235' },
|
||||
buttons: { backgroundColor: '#303235' },
|
||||
hostAvatar: { isEnabled: false },
|
||||
hostBubbles: { color: '#303235', backgroundColor: '#F7F8FF' },
|
||||
guestBubbles: { color: '#303235', backgroundColor: '#F7F8FF' },
|
||||
},
|
||||
general: {
|
||||
font: 'Inter',
|
||||
background: { type: BackgroundType.COLOR, content: '#ffffff' },
|
||||
font: {
|
||||
type: 'Google',
|
||||
family: 'Inter',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -84,19 +56,15 @@ export const galleryTemplates: Pick<ThemeTemplate, 'id' | 'name' | 'theme'>[] =
|
||||
name: 'Minimalist Teal',
|
||||
theme: {
|
||||
chat: {
|
||||
inputs: {
|
||||
color: '#303235',
|
||||
backgroundColor: '#FFFFFF',
|
||||
placeholderColor: '#9095A0',
|
||||
},
|
||||
buttons: { color: '#FFFFFF', backgroundColor: '#0d9488' },
|
||||
buttons: { backgroundColor: '#0d9488' },
|
||||
hostAvatar: { isEnabled: false },
|
||||
hostBubbles: { color: '#303235', backgroundColor: '#F7F8FF' },
|
||||
guestBubbles: { color: '#303235', backgroundColor: '#F7F8FF' },
|
||||
},
|
||||
general: {
|
||||
font: 'Inter',
|
||||
background: { type: BackgroundType.COLOR, content: '#ffffff' },
|
||||
font: {
|
||||
type: 'Google',
|
||||
family: 'Inter',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -106,18 +74,14 @@ export const galleryTemplates: Pick<ThemeTemplate, 'id' | 'name' | 'theme'>[] =
|
||||
name: 'Bright Rain',
|
||||
theme: {
|
||||
chat: {
|
||||
inputs: {
|
||||
color: '#303235',
|
||||
backgroundColor: '#FFFFFF',
|
||||
placeholderColor: '#9095A0',
|
||||
},
|
||||
buttons: { color: '#fff', backgroundColor: '#D27A7D' },
|
||||
hostAvatar: { isEnabled: true },
|
||||
hostBubbles: { color: '#303235', backgroundColor: '#F7F8FF' },
|
||||
buttons: { backgroundColor: '#D27A7D' },
|
||||
guestBubbles: { color: '#303235', backgroundColor: '#FDDDBF' },
|
||||
},
|
||||
general: {
|
||||
font: 'Montserrat',
|
||||
font: {
|
||||
type: 'Google',
|
||||
family: 'Montserrat',
|
||||
},
|
||||
background: {
|
||||
type: BackgroundType.IMAGE,
|
||||
content: getOrigin() + '/images/backgrounds/brightRain.jpeg',
|
||||
@@ -130,18 +94,14 @@ export const galleryTemplates: Pick<ThemeTemplate, 'id' | 'name' | 'theme'>[] =
|
||||
name: 'Ray of Lights',
|
||||
theme: {
|
||||
chat: {
|
||||
inputs: {
|
||||
color: '#303235',
|
||||
backgroundColor: '#FFFFFF',
|
||||
placeholderColor: '#9095A0',
|
||||
},
|
||||
buttons: { color: '#fff', backgroundColor: '#1A2249' },
|
||||
hostAvatar: { isEnabled: true },
|
||||
hostBubbles: { color: '#303235', backgroundColor: '#F7F8FF' },
|
||||
guestBubbles: { color: '#fff', backgroundColor: '#1A2249' },
|
||||
buttons: { backgroundColor: '#1A2249' },
|
||||
guestBubbles: { backgroundColor: '#1A2249' },
|
||||
},
|
||||
general: {
|
||||
font: 'Raleway',
|
||||
font: {
|
||||
type: 'Google',
|
||||
family: 'Raleway',
|
||||
},
|
||||
background: {
|
||||
type: BackgroundType.IMAGE,
|
||||
content: getOrigin() + '/images/backgrounds/rayOfLights.jpeg',
|
||||
|
||||
@@ -19398,7 +19398,52 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"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": {
|
||||
"type": "object",
|
||||
|
||||
@@ -6697,7 +6697,52 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"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": {
|
||||
"type": "object",
|
||||
|
||||
@@ -4,17 +4,20 @@ import { SEO } from './Seo'
|
||||
import { Typebot } from '@typebot.io/schemas/features/typebot/typebot'
|
||||
import { BackgroundType } from '@typebot.io/schemas/features/typebot/theme/constants'
|
||||
import { defaultSettings } from '@typebot.io/schemas/features/typebot/settings/constants'
|
||||
import { Font } from '@typebot.io/schemas'
|
||||
|
||||
export type TypebotV3PageProps = {
|
||||
url: string
|
||||
name: string
|
||||
publicId: string | null
|
||||
font: Font | null
|
||||
isHideQueryParamsEnabled: boolean | null
|
||||
background: NonNullable<Typebot['theme']['general']>['background']
|
||||
metadata: Typebot['settings']['metadata']
|
||||
}
|
||||
|
||||
export const TypebotPageV3 = ({
|
||||
font,
|
||||
publicId,
|
||||
name,
|
||||
url,
|
||||
@@ -51,7 +54,11 @@ export const TypebotPageV3 = ({
|
||||
}}
|
||||
>
|
||||
<SEO url={url} typebotName={name} metadata={metadata} />
|
||||
<Standard typebot={publicId} onInit={clearQueryParamsIfNecessary} />
|
||||
<Standard
|
||||
typebot={publicId}
|
||||
onInit={clearQueryParamsIfNecessary}
|
||||
font={font ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -116,6 +116,7 @@ const getTypebotFromPublicId = async (publicId?: string) => {
|
||||
publishedTypebot.settings.general?.isHideQueryParamsEnabled ??
|
||||
defaultSettings.general.isHideQueryParamsEnabled,
|
||||
metadata: publishedTypebot.settings.metadata ?? {},
|
||||
font: publishedTypebot.theme.general?.font ?? null,
|
||||
} satisfies Pick<
|
||||
TypebotV3PageProps,
|
||||
| 'name'
|
||||
@@ -123,6 +124,7 @@ const getTypebotFromPublicId = async (publicId?: string) => {
|
||||
| 'background'
|
||||
| 'isHideQueryParamsEnabled'
|
||||
| 'metadata'
|
||||
| 'font'
|
||||
>)
|
||||
: publishedTypebot
|
||||
}
|
||||
@@ -161,6 +163,7 @@ const getTypebotFromCustomDomain = async (customDomain: string) => {
|
||||
publishedTypebot.settings.general?.isHideQueryParamsEnabled ??
|
||||
defaultSettings.general.isHideQueryParamsEnabled,
|
||||
metadata: publishedTypebot.settings.metadata ?? {},
|
||||
font: publishedTypebot.theme.general?.font ?? null,
|
||||
} satisfies Pick<
|
||||
TypebotV3PageProps,
|
||||
| 'name'
|
||||
@@ -168,6 +171,7 @@ const getTypebotFromCustomDomain = async (customDomain: string) => {
|
||||
| 'background'
|
||||
| 'isHideQueryParamsEnabled'
|
||||
| 'metadata'
|
||||
| 'font'
|
||||
>)
|
||||
: publishedTypebot
|
||||
}
|
||||
@@ -196,6 +200,7 @@ const App = ({
|
||||
| 'background'
|
||||
| 'isHideQueryParamsEnabled'
|
||||
| 'metadata'
|
||||
| 'font'
|
||||
>
|
||||
incompatibleBrowser: string | null
|
||||
}) => {
|
||||
@@ -231,6 +236,7 @@ const App = ({
|
||||
publishedTypebot.background ?? defaultTheme.general.background
|
||||
}
|
||||
metadata={publishedTypebot.metadata ?? {}}
|
||||
font={publishedTypebot.font}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user