Skip to content

Commit 32855cb

Browse files
committed
email template form + contact list status
1 parent 71d895b commit 32855cb

File tree

5 files changed

+507
-47
lines changed

5 files changed

+507
-47
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

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

5+
## [28.0] - 2026-03-05
6+
7+
- **Templates**: Added ability to translate email templates to languages configured in workspace settings
8+
- **Contacts**: Fixed invalid "Blacklisted" status option in change status dropdown, replaced with valid "Bounced" and "Complained" statuses (#285)
9+
510
## [27.4] - 2026-03-01
611

712
- **Notification Center**: Fixed browser language auto-save overwriting contact's manually chosen language. Now only auto-detects when contact has no language set.

console/src/components/contacts/ContactDetailsDrawer.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -425,8 +425,7 @@ export function ContactDetailsDrawer({
425425
pending: 'orange',
426426
unsubscribed: 'red',
427427
bounced: 'volcano',
428-
complained: 'magenta',
429-
blacklisted: 'black'
428+
complained: 'magenta'
430429
}
431430
return statusColors[status.toLowerCase()] || 'blue'
432431
}
@@ -582,7 +581,8 @@ export function ContactDetailsDrawer({
582581
{ label: t`Active`, value: 'active' },
583582
{ label: t`Pending`, value: 'pending' },
584583
{ label: t`Unsubscribed`, value: 'unsubscribed' },
585-
{ label: t`Blacklisted`, value: 'blacklisted' }
584+
{ label: t`Bounced`, value: 'bounced' },
585+
{ label: t`Complained`, value: 'complained' }
586586
]
587587

588588
// If buttonProps is provided, render a button that opens the drawer

console/src/components/templates/CreateTemplateDrawer.tsx

Lines changed: 164 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ import type { MJMLComponentType } from '../email_builder/types'
3737
import MjmlCodeEditor, { STARTER_TEMPLATE } from '../email_builder/MjmlCodeEditor'
3838
import type { MjmlCodeEditorRef } from '../email_builder/MjmlCodeEditor'
3939
import type { MjmlCompileError } from '../../services/api/template'
40+
import { SUPPORTED_LANGUAGES } from '../../lib/languages'
41+
import TemplateTranslationsTab from './TemplateTranslationsTab'
42+
import type { TranslationEditorState } from './TemplateTranslationsTab'
43+
import type { TemplateTranslation } from '../../services/api/template'
4044

4145
/**
4246
* Validates liquid template tags in a string to ensure they are properly closed
@@ -189,6 +193,12 @@ export function CreateTemplateDrawer({
189193
if (fromTemplate?.email?.mjml_source) return fromTemplate.email.mjml_source
190194
return STARTER_TEMPLATE
191195
})
196+
const [translationsState, setTranslationsState] = useState<Record<string, TranslationEditorState>>({})
197+
198+
const translationLanguages = (workspace.settings.languages || []).filter(
199+
(l) => l !== workspace.settings.default_language
200+
)
201+
const showTranslationsTab = translationLanguages.length > 0
192202

193203
// Refs for tour targets
194204
const treePanelRef = useRef<HTMLDivElement>(null)
@@ -294,6 +304,25 @@ export function CreateTemplateDrawer({
294304
}
295305
}, [workspace.settings.custom_endpoint_url, workspace.id])
296306

307+
const loadTranslations = (translations?: Record<string, TemplateTranslation>) => {
308+
if (!translations) return
309+
const loaded: Record<string, TranslationEditorState> = {}
310+
for (const [lang, trans] of Object.entries(translations)) {
311+
loaded[lang] = {
312+
enabled: true,
313+
subject: trans.email?.subject || '',
314+
subjectPreview: trans.email?.subject_preview || '',
315+
visualEditorTree: trans.email?.visual_editor_tree
316+
? (typeof trans.email.visual_editor_tree === 'object'
317+
? (JSON.parse(JSON.stringify(trans.email.visual_editor_tree)) as EmailBlock)
318+
: (JSON.parse(trans.email.visual_editor_tree as unknown as string) as EmailBlock))
319+
: undefined,
320+
mjmlSource: trans.email?.mjml_source || undefined
321+
}
322+
}
323+
setTranslationsState(loaded)
324+
}
325+
297326
const showDrawer = () => {
298327
if (template) {
299328
// Set editor mode from existing template
@@ -315,6 +344,7 @@ export function CreateTemplateDrawer({
315344
},
316345
test_data: template.test_data || defaultTestData
317346
})
347+
loadTranslations(template.translations)
318348
} else if (fromTemplate) {
319349
// Clone template functionality - lock to source template's editor mode
320350
setEditorMode(fromTemplate.email?.editor_mode === 'code' ? 'code' : 'visual')
@@ -337,6 +367,8 @@ export function CreateTemplateDrawer({
337367
test_data: fromTemplate.test_data || defaultTestData
338368
})
339369

370+
loadTranslations(fromTemplate.translations)
371+
340372
// Update the visual editor tree
341373
if (fromTemplate.email?.visual_editor_tree) {
342374
if (typeof fromTemplate.email.visual_editor_tree === 'object') {
@@ -366,6 +398,7 @@ export function CreateTemplateDrawer({
366398
setTab('settings')
367399
setEditorMode('visual')
368400
setMjmlSource(STARTER_TEMPLATE)
401+
setTranslationsState({})
369402
if (onClose) {
370403
onClose()
371404
}
@@ -429,7 +462,11 @@ export function CreateTemplateDrawer({
429462
}
430463

431464
const goNext = () => {
432-
setTab('template')
465+
if (tab === 'settings') {
466+
setTab('template')
467+
} else if (tab === 'template' && showTranslationsTab) {
468+
setTab('translations')
469+
}
433470
}
434471

435472
return (
@@ -473,8 +510,28 @@ export function CreateTemplateDrawer({
473510
{t`Previous`}
474511
</Button>
475512
)}
476-
477-
{tab === 'template' && (
513+
{tab === 'template' && !showTranslationsTab && (
514+
<Button
515+
loading={loading || createTemplateMutation.isPending}
516+
onClick={() => {
517+
form.submit()
518+
}}
519+
type="primary"
520+
>
521+
{t`Save`}
522+
</Button>
523+
)}
524+
{tab === 'template' && showTranslationsTab && (
525+
<Button type="primary" onClick={goNext}>
526+
{t`Next`}
527+
</Button>
528+
)}
529+
{tab === 'translations' && (
530+
<Button type="primary" ghost onClick={() => setTab('template')}>
531+
{t`Previous`}
532+
</Button>
533+
)}
534+
{tab === 'translations' && (
478535
<Button
479536
loading={loading || createTemplateMutation.isPending}
480537
onClick={() => {
@@ -503,6 +560,41 @@ export function CreateTemplateDrawer({
503560
values.email.editor_mode = 'visual'
504561
values.email.visual_editor_tree = visualEditorTree
505562
}
563+
564+
// Validate and build translations from state
565+
if (showTranslationsTab) {
566+
// Check enabled translations have required fields
567+
for (const [lang, state] of Object.entries(translationsState)) {
568+
if (!state.enabled) continue
569+
if (!state.subject || !state.subjectPreview) {
570+
const langName = SUPPORTED_LANGUAGES[lang] || lang
571+
message.error(t`${langName} translation is missing required fields (subject and preview)`)
572+
setTab('translations')
573+
setLoading(false)
574+
return
575+
}
576+
}
577+
578+
const translations: Record<string, TemplateTranslation> = {}
579+
for (const [lang, state] of Object.entries(translationsState)) {
580+
if (!state.enabled) continue
581+
const emailTranslation: Record<string, unknown> = {
582+
subject: state.subject,
583+
subject_preview: state.subjectPreview || ''
584+
}
585+
if (editorMode === 'code') {
586+
emailTranslation.editor_mode = 'code'
587+
emailTranslation.mjml_source = state.mjmlSource || ''
588+
} else {
589+
emailTranslation.editor_mode = 'visual'
590+
emailTranslation.visual_editor_tree = state.visualEditorTree || visualEditorTree
591+
}
592+
translations[lang] = { email: emailTranslation as TemplateTranslation['email'] }
593+
}
594+
// Always send translations (even empty) so disabling all clears them on the server
595+
values.translations = translations
596+
}
597+
506598
createTemplateMutation.mutate(values)
507599
}}
508600
onFinishFailed={(info) => {
@@ -552,8 +644,18 @@ export function CreateTemplateDrawer({
552644
},
553645
{
554646
key: 'template',
555-
label: t`2. Template`
556-
}
647+
label: showTranslationsTab
648+
? t`2. Template (${workspace.settings.default_language})`
649+
: t`2. Template`
650+
},
651+
...(showTranslationsTab
652+
? [
653+
{
654+
key: 'translations',
655+
label: t`3. Translations`
656+
}
657+
]
658+
: [])
557659
]}
558660
/>
559661
</div>
@@ -653,21 +755,16 @@ export function CreateTemplateDrawer({
653755
</Col>
654756
<Col span={6}>
655757
<Form.Item label={t`Editor mode`}>
656-
{template || fromTemplate ? (
657-
<Tag color={editorMode === 'code' ? 'geekblue' : 'default'}>
658-
{editorMode === 'code' ? t`Code (MJML)` : t`Visual`}
659-
</Tag>
660-
) : (
661-
<Radio.Group
662-
value={editorMode}
663-
onChange={(e) => setEditorMode(e.target.value as 'visual' | 'code')}
664-
optionType="button"
665-
options={[
666-
{ label: t`Visual`, value: 'visual' },
667-
{ label: t`Code (MJML)`, value: 'code' }
668-
]}
669-
/>
670-
)}
758+
<Radio.Group
759+
value={editorMode}
760+
onChange={(e) => setEditorMode(e.target.value as 'visual' | 'code')}
761+
optionType="button"
762+
disabled={!!(template || fromTemplate)}
763+
options={[
764+
{ label: t`Visual`, value: 'visual' },
765+
{ label: t`Code (MJML)`, value: 'code' }
766+
]}
767+
/>
671768
</Form.Item>
672769
</Col>
673770
</Row>
@@ -677,7 +774,7 @@ export function CreateTemplateDrawer({
677774
<Col span={12}>
678775
<Form.Item
679776
name={['email', 'subject']}
680-
label={t`Email subject`}
777+
label={showTranslationsTab ? t`Email subject (${workspace.settings.default_language})` : t`Email subject`}
681778
rules={[
682779
{ required: true, type: 'string' },
683780
{
@@ -695,7 +792,7 @@ export function CreateTemplateDrawer({
695792
</Form.Item>
696793
<Form.Item
697794
name={['email', 'subject_preview']}
698-
label={t`Subject preview`}
795+
label={showTranslationsTab ? t`Subject preview (${workspace.settings.default_language})` : t`Subject preview`}
699796
rules={[
700797
{ required: true, type: 'string' },
701798
{
@@ -712,27 +809,32 @@ export function CreateTemplateDrawer({
712809
<Input placeholder={t`Templating markup allowed`} />
713810
</Form.Item>
714811

715-
<Form.Item
716-
name={['email', 'reply_to']}
717-
label={t`Reply to`}
718-
rules={[{ required: false, type: 'email' }]}
719-
>
720-
<Input />
721-
</Form.Item>
722-
723-
<Form.Item
724-
name={['email', 'sender_id']}
725-
label={categoryValue === 'marketing' ? t`Custom sender (marketing email provider)` : t`Custom sender (transactional email provider)`}
726-
rules={[{ required: false, type: 'string' }]}
727-
>
728-
<Select
729-
options={emailProvider?.email_provider?.senders.map((sender) => ({
730-
value: sender.id,
731-
label: `${sender.name} <${sender.email}>`
732-
}))}
733-
allowClear={true}
734-
/>
735-
</Form.Item>
812+
<Row gutter={24}>
813+
<Col span={12}>
814+
<Form.Item
815+
name={['email', 'reply_to']}
816+
label={t`Reply to`}
817+
rules={[{ required: false, type: 'email' }]}
818+
>
819+
<Input />
820+
</Form.Item>
821+
</Col>
822+
<Col span={12}>
823+
<Form.Item
824+
name={['email', 'sender_id']}
825+
label={categoryValue === 'marketing' ? t`Custom sender (marketing email provider)` : t`Custom sender (transactional email provider)`}
826+
rules={[{ required: false, type: 'string' }]}
827+
>
828+
<Select
829+
options={emailProvider?.email_provider?.senders.map((sender) => ({
830+
value: sender.id,
831+
label: `${sender.name} <${sender.email}>`
832+
}))}
833+
allowClear={true}
834+
/>
835+
</Form.Item>
836+
</Col>
837+
</Row>
736838
</Col>
737839
<Col span={12}>
738840
<div className="flex justify-center">
@@ -900,6 +1002,25 @@ export function CreateTemplateDrawer({
9001002
}}
9011003
</Form.Item>
9021004
</div>
1005+
1006+
{showTranslationsTab && (
1007+
<div style={{ display: tab === 'translations' ? 'block' : 'none' }}>
1008+
<TemplateTranslationsTab
1009+
workspace={workspace}
1010+
editorMode={editorMode}
1011+
translationsState={translationsState}
1012+
onTranslationsStateChange={setTranslationsState}
1013+
defaultSubject={emailSubject}
1014+
defaultSubjectPreview={emailPreview}
1015+
defaultVisualEditorTree={visualEditorTree}
1016+
defaultMjmlSource={mjmlSource}
1017+
testData={form.getFieldValue('test_data')}
1018+
onTestDataChange={(newTestData) => form.setFieldsValue({ test_data: newTestData })}
1019+
savedBlocks={workspace.settings.template_blocks || []}
1020+
onSaveBlock={handleSaveBlock}
1021+
/>
1022+
</div>
1023+
)}
9031024
</div>
9041025
</Form>
9051026
<Tour

console/src/components/templates/PhonePreview.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { Signal, Wifi, BatteryFull, ChevronRight } from 'lucide-react'
88
const styles: Record<string, React.CSSProperties> = {
99
iphoneMockupUpper: {
1010
width: '375px',
11-
height: '350px',
11+
height: '260px',
1212
border: '12px solid black',
1313
borderBottom: 'none',
1414
borderTopLeftRadius: '50px',

0 commit comments

Comments
 (0)