Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
cba4bc8
Add template i18n design document
Swahjak Feb 24, 2026
a547160
Add template i18n implementation plan
Swahjak Feb 24, 2026
af9ab03
Add documentation update plan for template i18n
Swahjak Feb 24, 2026
e004a94
docs: add v28.0 changelog entry for template i18n
Swahjak Feb 24, 2026
5d965b6
docs: add translations fields to OpenAPI template schema
Swahjak Feb 24, 2026
8850797
docs: update CLAUDE.md with template i18n documentation
Swahjak Feb 24, 2026
363da7b
docs: fix design doc migration section — language settings go in Work…
Swahjak Feb 24, 2026
49aa61c
docs: add OpenAPI schema for workspace translations
Swahjak Feb 24, 2026
88431c6
feat(i18n): add translation utility functions
Swahjak Feb 24, 2026
5ba74a1
docs: add OpenAPI paths for workspace translations API
Swahjak Feb 24, 2026
d5384b9
docs: register workspace translations in OpenAPI root
Swahjak Feb 24, 2026
ab17189
feat(i18n): add Liquid t filter for translation key resolution
Swahjak Feb 24, 2026
3f16926
docs: add external docs update design for template i18n
Swahjak Feb 24, 2026
17cc4e5
feat(i18n): add translation fields to domain models
Swahjak Feb 24, 2026
0cdf958
docs: add external docs implementation plan for template i18n
Swahjak Feb 24, 2026
a84f76c
feat(i18n): add V28 migration for template translations
Swahjak Feb 24, 2026
507faa8
feat(i18n): add workspace translation service
Swahjak Feb 24, 2026
6ad9b0b
fix: update migration manager test for V28
Swahjak Feb 24, 2026
bd417b0
feat(i18n): add frontend API types and service for translations
Swahjak Feb 24, 2026
0a7e669
feat(i18n): add workspace language settings UI
Swahjak Feb 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,26 @@

All notable changes to this project will be documented in this file.

## [28.0] - 2026-XX-XX

### New Features

- **Template i18n**: Auto-select email content based on contact language (#268)
- **Liquid `t` filter**: Use `{{ "key" | t }}` in templates to reference translation keys
- **Placeholder support**: Pass dynamic values with `{{ "greeting" | t: name: contact.first_name }}`
- **Nested keys**: Dot-separated key paths (e.g., `welcome.heading`, `cta.button`)
- **Per-template translations**: Store translation key-value maps per locale as part of the template
- **Workspace translations**: Shared translation catalog available to all templates in a workspace
- **Automatic locale resolution**: Fallback chain from `contact.language` → base language → template default → workspace default
- **Translations panel**: Manage translation keys and per-locale values in the template editor
- **Import/Export**: Bulk upload/download translations as JSON files per locale
- **Workspace language settings**: Configure default language and supported languages in workspace settings

### Database Migration

- Added `translations` JSONB column and `default_language` VARCHAR column to `templates` table (workspace migration)
- Created `workspace_translations` table for workspace-level shared translations (workspace migration)

## [27.2] - 2026-02-21

- **Contacts**: Fixed panic (502) when calling `/api/contacts.list` without the `limit` parameter (#264)
Expand Down
13 changes: 13 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,19 @@ func (m *V6Migration) UpdateSystem(ctx context.Context, config *config.Config, d
- **MJML Support**: gomjml v0.10.0 for email rendering
- **HTML Parsing**: PuerkitoBio/goquery v1.10.3

#### Translation Filter (v28.0+)

Templates can reference translatable strings using the Liquid `t` filter:

```liquid
{{ "welcome.heading" | t }}
{{ "welcome.greeting" | t: name: contact.first_name }}
```

Translations are stored per-locale as nested JSON on the template's `translations` field. The system resolves the best locale from `contact.language` with a fallback chain: exact match → base language → template default → workspace default.

Workspace-level shared translations (in the `workspace_translations` table) serve as a fallback when a key is not found in the template's own translations. Template translations take priority over workspace translations.

### Observability & Monitoring

- **Logging**: Zerolog v1.33.0 (structured logging)
Expand Down
2 changes: 1 addition & 1 deletion config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import (
"github.com/spf13/viper"
)

const VERSION = "27.2"
const VERSION = "28.0"

type Config struct {
Server ServerConfig
Expand Down
182 changes: 182 additions & 0 deletions console/src/components/settings/LanguageSettings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import { useEffect, useState } from 'react'
import { Button, Form, Select, App, Descriptions } from 'antd'
import { useLingui } from '@lingui/react/macro'
import { Workspace } from '../../services/api/types'
import { workspaceService } from '../../services/api/workspace'
import { SettingsSectionHeader } from './SettingsSectionHeader'

const LANGUAGE_OPTIONS = [
{ value: 'en', label: 'English' },
{ value: 'fr', label: 'French' },
{ value: 'de', label: 'German' },
{ value: 'es', label: 'Spanish' },
{ value: 'pt', label: 'Portuguese' },
{ value: 'pt-BR', label: 'Portuguese (Brazil)' },
{ value: 'it', label: 'Italian' },
{ value: 'nl', label: 'Dutch' },
{ value: 'ja', label: 'Japanese' },
{ value: 'ko', label: 'Korean' },
{ value: 'zh', label: 'Chinese' },
{ value: 'ru', label: 'Russian' },
{ value: 'ar', label: 'Arabic' },
{ value: 'hi', label: 'Hindi' },
{ value: 'tr', label: 'Turkish' },
{ value: 'pl', label: 'Polish' },
{ value: 'sv', label: 'Swedish' },
{ value: 'da', label: 'Danish' },
{ value: 'fi', label: 'Finnish' },
{ value: 'nb', label: 'Norwegian' }
]

interface LanguageSettingsProps {
workspace: Workspace | null
onWorkspaceUpdate: (workspace: Workspace) => void
isOwner: boolean
}

export function LanguageSettings({ workspace, onWorkspaceUpdate, isOwner }: LanguageSettingsProps) {
const { t } = useLingui()
const [savingSettings, setSavingSettings] = useState(false)
const [formTouched, setFormTouched] = useState(false)
const [form] = Form.useForm()
const { message } = App.useApp()

useEffect(() => {
if (!isOwner) return

form.setFieldsValue({
default_language: workspace?.settings.default_language || 'en',
supported_languages: workspace?.settings.supported_languages || ['en']
})
setFormTouched(false)
}, [workspace, form, isOwner])

const handleSaveSettings = async (values: {
default_language: string
supported_languages: string[]
}) => {
if (!workspace) return

// Ensure the default language is included in supported languages
let supportedLanguages = values.supported_languages || []
if (!supportedLanguages.includes(values.default_language)) {
supportedLanguages = [values.default_language, ...supportedLanguages]
}

setSavingSettings(true)
try {
await workspaceService.update({
...workspace,
settings: {
...workspace.settings,
default_language: values.default_language,
supported_languages: supportedLanguages
}
})

// Refresh the workspace data
const response = await workspaceService.get(workspace.id)

// Update the parent component with the new workspace data
onWorkspaceUpdate(response.workspace)

setFormTouched(false)
message.success(t`Language settings updated successfully`)
} catch (error: unknown) {
console.error('Failed to update language settings', error)
const errorMessage = (error as Error)?.message || t`Failed to update language settings`
message.error(errorMessage)
} finally {
setSavingSettings(false)
}
}

const handleFormChange = () => {
setFormTouched(true)
}

const getLabelForLanguage = (code: string) => {
const option = LANGUAGE_OPTIONS.find((o) => o.value === code)
return option ? option.label : code
}

if (!isOwner) {
const defaultLang = workspace?.settings.default_language || 'en'
const supportedLangs = workspace?.settings.supported_languages || ['en']

return (
<>
<SettingsSectionHeader
title={t`Languages`}
description={t`Language configuration for templates and content`}
/>

<Descriptions
bordered
column={1}
size="small"
styles={{ label: { width: '200px', fontWeight: '500' } }}
>
<Descriptions.Item label={t`Default Language`}>
{getLabelForLanguage(defaultLang)}
</Descriptions.Item>

<Descriptions.Item label={t`Supported Languages`}>
{supportedLangs.map((lang) => getLabelForLanguage(lang)).join(', ')}
</Descriptions.Item>
</Descriptions>
</>
)
}

return (
<>
<SettingsSectionHeader
title={t`Languages`}
description={t`Configure the default language and supported languages for your workspace templates and content.`}
/>

<Form
form={form}
layout="vertical"
onFinish={handleSaveSettings}
onValuesChange={handleFormChange}
>
<Form.Item
name="default_language"
label={t`Default Language`}
tooltip={t`The primary language used for templates when no specific language is specified.`}
rules={[{ required: true, message: t`Please select a default language` }]}
>
<Select
options={LANGUAGE_OPTIONS}
showSearch
optionFilterProp="label"
placeholder={t`Select default language`}
/>
</Form.Item>

<Form.Item
name="supported_languages"
label={t`Supported Languages`}
tooltip={t`Languages available for template translations. The default language is always included.`}
rules={[{ required: true, message: t`Please select at least one supported language` }]}
>
<Select
mode="multiple"
options={LANGUAGE_OPTIONS}
showSearch
optionFilterProp="label"
placeholder={t`Select supported languages`}
/>
</Form.Item>

<Form.Item>
<Button type="primary" htmlType="submit" loading={savingSettings} disabled={!formTouched}>
{t`Save Changes`}
</Button>
</Form.Item>
</Form>
</>
)
}
9 changes: 8 additions & 1 deletion console/src/components/settings/SettingsSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import {
TagsOutlined,
SettingOutlined,
ExclamationCircleOutlined,
MailOutlined
MailOutlined,
GlobalOutlined
} from '@ant-design/icons'
import { useLingui } from '@lingui/react/macro'

Expand All @@ -15,6 +16,7 @@ export type SettingsSection =
| 'custom-fields'
| 'smtp-relay'
| 'general'
| 'languages'
| 'blog'
| 'danger-zone'

Expand Down Expand Up @@ -107,6 +109,11 @@ export function SettingsSidebar({ activeSection, onSectionChange, isOwner }: Set
icon: <MailOutlined />,
label: t`SMTP Relay`
},
{
key: 'languages',
icon: <GlobalOutlined />,
label: t`Languages`
},
{
key: 'general',
icon: <SettingOutlined />,
Expand Down
10 changes: 10 additions & 0 deletions console/src/pages/WorkspaceSettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { BlogSettings } from '../components/settings/BlogSettings'
import { WebhooksSettings } from '../components/settings/WebhooksSettings'
import { useAuth } from '../contexts/AuthContext'
import { DeleteWorkspaceSection } from '../components/settings/DeleteWorkspace'
import { LanguageSettings } from '../components/settings/LanguageSettings'
import { SettingsSidebar, SettingsSection } from '../components/settings/SettingsSidebar'

const { Sider, Content } = Layout
Expand All @@ -37,6 +38,7 @@ export function WorkspaceSettingsPage() {
'custom-fields',
'smtp-relay',
'general',
'languages',
'blog',
'danger-zone'
]
Expand Down Expand Up @@ -144,6 +146,14 @@ export function WorkspaceSettingsPage() {
isOwner={isOwner}
/>
)
case 'languages':
return (
<LanguageSettings
workspace={workspace}
onWorkspaceUpdate={handleWorkspaceUpdate}
isOwner={isOwner}
/>
)
case 'blog':
return (
<BlogSettings
Expand Down
2 changes: 2 additions & 0 deletions console/src/services/api/template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export interface Template {
utm_campaign?: string
test_data?: Record<string, unknown>
settings?: Record<string, unknown>
translations?: Record<string, Record<string, unknown>> // locale → nested key-value
default_language?: string
created_at: string
updated_at: string
}
Expand Down
43 changes: 43 additions & 0 deletions console/src/services/api/workspace-translations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { api } from './client'

export interface WorkspaceTranslation {
locale: string
content: Record<string, unknown>
created_at: string
updated_at: string
}

export interface UpsertWorkspaceTranslationRequest {
workspace_id: string
locale: string
content: Record<string, unknown>
}

export interface ListWorkspaceTranslationsResponse {
translations: WorkspaceTranslation[]
}

export interface DeleteWorkspaceTranslationRequest {
workspace_id: string
locale: string
}

export interface WorkspaceTranslationsApi {
list: (workspaceId: string) => Promise<ListWorkspaceTranslationsResponse>
upsert: (params: UpsertWorkspaceTranslationRequest) => Promise<void>
delete: (params: DeleteWorkspaceTranslationRequest) => Promise<void>
}

export const workspaceTranslationsApi: WorkspaceTranslationsApi = {
list: async (workspaceId: string) => {
return api.get<ListWorkspaceTranslationsResponse>(
`/api/workspace_translations.list?workspace_id=${workspaceId}`
)
},
upsert: async (params: UpsertWorkspaceTranslationRequest) => {
return api.post<void>('/api/workspace_translations.upsert', params)
},
delete: async (params: DeleteWorkspaceTranslationRequest) => {
return api.post<void>('/api/workspace_translations.delete', params)
}
}
Loading