From 7cf26795ec12845743a30d48956381f25c6e181c Mon Sep 17 00:00:00 2001
From: Paul Nothaft
Date: Sat, 20 Jun 2026 23:17:34 +0200
Subject: [PATCH 1/2] fix(branding): preserve customCss through preset switches
+ theme changes (#645)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Reporter @aemisrogers nailed the root cause: same #317 class of bug as
logoUrl. None of `GALLERY_THEME_PRESETS` (`theme.types.ts:125`) include
`customCss` in their `config` object, so any path that REPLACES
`currentTheme` with `preset.config` (or with a sparse `newTheme` that
came from `preset.config` upstream) silently dropped `customCss` from
React state. The persisted value in `theme_config` stayed correct (the
public gallery still rendered it), but the admin textarea showed
empty on reload — admin-UI display drift, not data loss.
Three surgical fixes, mirroring the #317 logoUrl pattern:
1. `BrandingPage.tsx` `handleThemeChange` — `customCss: newTheme.customCss
?? currentTheme.customCss` alongside the existing `logoUrl` fallback.
Closes the propagation hole where the customizer's `handlePresetSelect`
fires `onChange(preset.config)` (no customCss) and the parent wipes
it from currentTheme.
2. `BrandingPage.tsx` `handlePresetChange` — preserve `customCss` from
prev/currentTheme on preset switch, same shape as the existing
`logoUrl: prev.logoUrl` preservation. Touches both the `setCurrentTheme`
and the preview-mode `setTheme` paths.
3. `ThemeCustomizerEnhanced.tsx` `handlePresetSelect` — remove the
`setCustomCss('')` that wiped the local textarea state on preset
pick. The previous comment ("Clear custom CSS when selecting a preset")
described the original intent but produced data drift across the
preset round-trip. The sibling `ThemeCustomizer.tsx` already never
cleared it; this aligns the two.
Verified against `v3.44.0` and `origin/beta`: identical code on both
branches, so the bug exists on stable + beta. Lint + tsc clean on the
two changed files.
Closes #645.
---
.../admin/ThemeCustomizerEnhanced.tsx | 8 +++++-
frontend/src/pages/admin/BrandingPage.tsx | 26 ++++++++++++++-----
2 files changed, 26 insertions(+), 8 deletions(-)
diff --git a/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx b/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx
index 9a08c407..b0901fdd 100644
--- a/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx
+++ b/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx
@@ -259,7 +259,13 @@ export const ThemeCustomizerEnhanced: React.FC = (
if (preset) {
setSelectedPreset(presetKey);
setLocalTheme(preset.config);
- setCustomCss(''); // Clear custom CSS when selecting a preset
+ // Don't wipe customCss on preset pick — preset configs carry no
+ // customCss, and the admin's persisted styling extras should
+ // survive a layout switch (#645). Matches ThemeCustomizer.tsx
+ // which never cleared it. The parent's handleThemeChange merges
+ // via `customCss: newTheme.customCss ?? currentTheme.customCss`,
+ // so propagating preset.config (no customCss) keeps the saved
+ // value intact end-to-end.
if (onPresetChange) {
onPresetChange(presetKey);
}
diff --git a/frontend/src/pages/admin/BrandingPage.tsx b/frontend/src/pages/admin/BrandingPage.tsx
index d0fa47ea..dc3e0371 100644
--- a/frontend/src/pages/admin/BrandingPage.tsx
+++ b/frontend/src/pages/admin/BrandingPage.tsx
@@ -186,12 +186,14 @@ export const BrandingPage: React.FC = () => {
};
const handleThemeChange = (newTheme: ThemeConfig) => {
- // Preset configs don't carry a logoUrl, so a preset change inside the
- // customizer arrives here with newTheme.logoUrl=undefined. Keep the
- // existing logo instead of wiping branding_logo_url on save (#317).
+ // Preset configs don't carry a logoUrl or customCss, so a preset change
+ // inside the customizer arrives here with those fields undefined. Keep
+ // the existing values instead of wiping the persisted ones on save
+ // (#317 for logoUrl, #645 for customCss).
const mergedTheme: ThemeConfig = {
...newTheme,
- logoUrl: newTheme.logoUrl ?? currentTheme.logoUrl
+ logoUrl: newTheme.logoUrl ?? currentTheme.logoUrl,
+ customCss: newTheme.customCss ?? currentTheme.customCss
};
setCurrentTheme(mergedTheme);
if (newTheme.logoUrl !== undefined && newTheme.logoUrl !== currentTheme.logoUrl) {
@@ -207,10 +209,20 @@ export const BrandingPage: React.FC = () => {
// Get the preset theme config
const preset = GALLERY_THEME_PRESETS[presetName];
if (preset) {
- // Preserve the existing logo when switching presets (#317).
- setCurrentTheme(prev => ({ ...preset.config, logoUrl: prev.logoUrl }));
+ // Preserve the existing logo + custom CSS when switching presets
+ // (#317 for logo, #645 for customCss). Presets define a look; they
+ // shouldn't silently drop the admin's persisted styling extras.
+ setCurrentTheme(prev => ({
+ ...preset.config,
+ logoUrl: prev.logoUrl,
+ customCss: prev.customCss
+ }));
if (isPreviewMode) {
- setTheme({ ...preset.config, logoUrl: currentTheme.logoUrl });
+ setTheme({
+ ...preset.config,
+ logoUrl: currentTheme.logoUrl,
+ customCss: currentTheme.customCss
+ });
}
}
};
From 4fd7709596e7a0dd3fedef63772ddd52ce5561c9 Mon Sep 17 00:00:00 2001
From: Paul Nothaft
Date: Sat, 20 Jun 2026 23:29:08 +0200
Subject: [PATCH 2/2] fix(whatsapp): admin-pinned template language + Arabic
locale support (#647)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Reporter @Rekoo-PS hit three independent gaps trying to deliver an
Arabic Meta template. Bundled here because they fan out from the same
root cause (no first-class language config on the WhatsApp tab) and the
review surfaces are tightly coupled.
**1. Test send hardcoded `en_US` (`adminWhatsapp.js:141`).** Smoking gun
for "I can't make it work" — Meta returned template_not_found_in_language
(132001) on every test send for non-English templates, no matter what
else the admin configured. Replaced with `config.template_language ||
'en_US'`.
**2. No `template_language` field on `whatsapp_configs`.** The only
priors were per-message `data.language` (always null from our callers in
`adminEvents.js:854,1188`) and `app_settings.general_default_language`
(the *system UI* language, not the *template's* language registered with
Meta). Migration 137 adds the column; GET + PUT surface it; the
processor uses it as the highest-priority default when message_data
doesn't override.
Resolution order in `whatsappProcessor.processWhatsAppQueue` is now:
1. message_data.language (per-event override — caller path TBD)
2. config.template_language (admin-pinned template language)
3. app_settings.general_default_language (system fallback)
4. en_US (hardcoded last resort)
**3. `LANGUAGE_MAP` + `PASSWORD_LABELS` didn't cover Arabic.** Added
`ar` (Meta's single-code form per RFC; no region variant). For any
language we don't enumerate (e.g. Turkish `tr_TR`, Chinese `zh_CN`,
Hebrew `he_IL`), `resolveLanguageCode` now pass-throughs valid-shape
codes (lowercase-language + optional underscore + uppercase-region) and
forwards them to Meta as-is. If they don't match a registered template
Meta returns 132001, which the test route already surfaces back to the
admin via `error.message` — fail-loud, no silent fallback.
Validation:
- Unit smoke on `resolveLanguageCode` across 18 representative inputs
(in-map, pass-through, canonicalization, rejection) — all behaviours
correct.
- Lint clean on all 7 changed files.
- Frontend `tsc --noEmit` clean.
- Migration `node -c` syntax-checked; additive + `hasColumn`-guarded so
re-running is safe.
Frontend: free-text input on the WhatsApp tab with EN + DE i18n.
Pointing at Meta's supported-languages docs via the hint text — Meta's
list grows; a hardcoded dropdown would rot.
Closes #647.
---
.../137_add_whatsapp_template_language.js | 31 ++++++++++++++
backend/src/routes/adminWhatsapp.js | 20 ++++++++-
backend/src/services/whatsappProcessor.js | 41 +++++++++++++++++--
.../features/settings/tabs/WhatsAppTab.tsx | 20 +++++++++
frontend/src/i18n/locales/de.json | 3 ++
frontend/src/i18n/locales/en.json | 3 ++
frontend/src/services/whatsapp.service.ts | 5 +++
7 files changed, 118 insertions(+), 5 deletions(-)
create mode 100644 backend/migrations/core/137_add_whatsapp_template_language.js
diff --git a/backend/migrations/core/137_add_whatsapp_template_language.js b/backend/migrations/core/137_add_whatsapp_template_language.js
new file mode 100644
index 00000000..f7f8c764
--- /dev/null
+++ b/backend/migrations/core/137_add_whatsapp_template_language.js
@@ -0,0 +1,31 @@
+/**
+ * Migration 137: WhatsApp template language (#647).
+ *
+ * Adds a `template_language` column to `whatsapp_configs` so admins can
+ * pin their Meta-approved template's language code (e.g. `ar`, `en_US`,
+ * `de_DE`) directly in Settings → WhatsApp. Without this column the only
+ * resolution paths were per-message `data.language` (always null in our
+ * own callers) and `app_settings.general_default_language` — both of
+ * which are tied to the *system* UI language, not the *template's* language
+ * registered with Meta. Reporter @Rekoo-PS hit this with an Arabic
+ * template against the test-send route.
+ *
+ * Additive + hasColumn-guarded. Empty string default means "fall through
+ * to general_default_language" — preserves current behaviour for installs
+ * that don't set it.
+ */
+exports.up = async function (knex) {
+ if (!(await knex.schema.hasTable('whatsapp_configs'))) return;
+ if (await knex.schema.hasColumn('whatsapp_configs', 'template_language')) return;
+ await knex.schema.alterTable('whatsapp_configs', (table) => {
+ table.string('template_language', 20).notNullable().defaultTo('');
+ });
+};
+
+exports.down = async function (knex) {
+ if (!(await knex.schema.hasTable('whatsapp_configs'))) return;
+ if (!(await knex.schema.hasColumn('whatsapp_configs', 'template_language'))) return;
+ await knex.schema.alterTable('whatsapp_configs', (table) => {
+ table.dropColumn('template_language');
+ });
+};
diff --git a/backend/src/routes/adminWhatsapp.js b/backend/src/routes/adminWhatsapp.js
index 411dbd3f..b21d5a4c 100644
--- a/backend/src/routes/adminWhatsapp.js
+++ b/backend/src/routes/adminWhatsapp.js
@@ -37,6 +37,7 @@ router.get('/config', adminAuth, requirePermission('settings.view'), async (req,
waba_id: '',
access_token: '',
template_name: 'gallery_ready',
+ template_language: '',
enabled: false,
});
}
@@ -45,6 +46,7 @@ router.get('/config', adminAuth, requirePermission('settings.view'), async (req,
waba_id: config.waba_id,
access_token: config.access_token ? '********' : '',
template_name: config.template_name,
+ template_language: config.template_language || '',
enabled: Boolean(config.enabled),
});
} catch (error) {
@@ -55,15 +57,25 @@ router.get('/config', adminAuth, requirePermission('settings.view'), async (req,
router.put('/config', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
- const { phone_number_id, waba_id, access_token, template_name, enabled } = req.body;
+ const { phone_number_id, waba_id, access_token, template_name, template_language, enabled } = req.body;
const existing = await db('whatsapp_configs').first();
const isEnabled = Boolean(enabled);
+ // Meta template-language codes are BCP-47 style: `ar`, `en_US`, `de_DE`,
+ // `pt_BR`, etc. We accept arbitrary strings and pass through to Meta —
+ // they'll return template_not_found_in_language (132001) if the code
+ // doesn't match a registered template. No client-side allowlist because
+ // Meta's supported-languages list changes.
+ const normalizedTemplateLanguage = typeof template_language === 'string'
+ ? template_language.trim().slice(0, 20)
+ : '';
+
const data = {
phone_number_id: phone_number_id || '',
waba_id: waba_id || '',
template_name: template_name || 'gallery_ready',
+ template_language: normalizedTemplateLanguage,
enabled: isEnabled,
updated_at: new Date(),
};
@@ -138,7 +150,11 @@ router.post('/test', adminAuth, requirePermission('settings.edit'), async (req,
'',
];
- const result = await sendWhatsAppMessage(phone, config, 'en_US', testComponents);
+ // Use the configured template language so non-English templates can be
+ // tested too (#647). Falls back to en_US for the default `gallery_ready`
+ // shape that ships in English.
+ const language = (config.template_language && config.template_language.trim()) || 'en_US';
+ const result = await sendWhatsAppMessage(phone, config, language, testComponents);
res.json({ success: true, messageId: result.messageId });
} catch (error) {
logger.error('WhatsApp test send error:', error);
diff --git a/backend/src/services/whatsappProcessor.js b/backend/src/services/whatsappProcessor.js
index b2796273..6f40b4c0 100644
--- a/backend/src/services/whatsappProcessor.js
+++ b/backend/src/services/whatsappProcessor.js
@@ -33,6 +33,10 @@ const LANGUAGE_MAP = {
fr: 'fr_FR', 'fr-fr': 'fr_FR', 'fr_fr': 'fr_FR',
es: 'es_ES', 'es-es': 'es_ES', 'es_es': 'es_ES',
it: 'it_IT', 'it-it': 'it_IT', 'it_it': 'it_IT',
+ // Meta lists Arabic as a single code `ar` (no region variant). The common
+ // BCP-47 region variants land on the same Meta code (#647).
+ ar: 'ar', 'ar-sa': 'ar', 'ar_sa': 'ar', 'ar-eg': 'ar', 'ar_eg': 'ar',
+ 'ar-ar': 'ar', 'ar_ar': 'ar',
};
// Per-locale label embedded in the {{4}} password line. Meta templates only
@@ -47,12 +51,16 @@ const PASSWORD_LABELS = {
fr_FR: '🔒 Mot de passe',
es_ES: '🔒 Contraseña',
it_IT: '🔒 Password',
+ ar: '🔒 كلمة المرور',
};
const INTL_LOCALE_MAP = {
pt_BR: 'pt-BR', en_US: 'en-US', de_DE: 'de-DE',
ru_RU: 'ru-RU', nl_NL: 'nl-NL', fr_FR: 'fr-FR',
es_ES: 'es-ES', it_IT: 'it-IT',
+ // Pick a representative region for Arabic date formatting. Meta has no
+ // region variant on the template code, but JS Intl needs one.
+ ar: 'ar-SA',
};
const POLL_INTERVAL_MS = parseInt(process.env.WHATSAPP_QUEUE_POLL_MS || '30000', 10);
@@ -64,11 +72,29 @@ let pollHandle = null;
/**
* Resolve a Meta template language code from whatever's in the message_data
* (admin-set per-event language) or the system default.
+ *
+ * Pass-through fallback (#647): if the admin types a code we don't have in
+ * the map (e.g. `tr_TR` for Turkish, `zh_CN` for Chinese), but it matches
+ * the Meta BCP-47 shape, we trust them and forward it as-is. Meta returns
+ * template_not_found_in_language (132001) if the code doesn't match a
+ * registered template, surfacing the typo back to the admin via the test
+ * route's error response.
*/
function resolveLanguageCode(lang) {
if (!lang) return null; // signal: caller should fall through to default
- const normalised = String(lang).toLowerCase().replace(/-/g, '_');
- return LANGUAGE_MAP[normalised] || null;
+ const raw = String(lang).trim();
+ if (!raw) return null;
+ const normalised = raw.toLowerCase().replace(/-/g, '_');
+ if (LANGUAGE_MAP[normalised]) return LANGUAGE_MAP[normalised];
+ // Pass-through for valid-shape Meta codes the map doesn't enumerate.
+ // Accept `xx` or `xx_YY` (case-insensitive on input); emit Meta's
+ // canonical lowercase-language + uppercase-region form.
+ const passthrough = /^([a-z]{2})(?:[_-]([a-z]{2}))?$/i.exec(raw);
+ if (passthrough) {
+ const [, langPart, regionPart] = passthrough;
+ return regionPart ? `${langPart.toLowerCase()}_${regionPart.toUpperCase()}` : langPart.toLowerCase();
+ }
+ return null;
}
async function getSystemDefaultLanguageCode() {
@@ -175,7 +201,16 @@ async function processWhatsAppQueue() {
}
if (!config || !config.enabled || !config.phone_number_id || !config.access_token) return;
- const defaultLanguage = await getSystemDefaultLanguageCode();
+ // Priority order for the *default* (when message_data.language is null):
+ // 1. config.template_language — admin-pinned to match their Meta-registered
+ // template (#647). This is the only way to send Arabic/Chinese/etc.
+ // templates correctly, since general_default_language is the UI
+ // language not the template's.
+ // 2. general_default_language — system fallback for installs that haven't
+ // pinned a template_language.
+ // 3. en_US — hardcoded last resort.
+ const configLanguage = resolveLanguageCode(config.template_language);
+ const defaultLanguage = configLanguage || await getSystemDefaultLanguageCode();
let pending;
try {
diff --git a/frontend/src/features/settings/tabs/WhatsAppTab.tsx b/frontend/src/features/settings/tabs/WhatsAppTab.tsx
index 97f17ad5..0420e96b 100644
--- a/frontend/src/features/settings/tabs/WhatsAppTab.tsx
+++ b/frontend/src/features/settings/tabs/WhatsAppTab.tsx
@@ -31,6 +31,7 @@ export const WhatsAppTab: React.FC = () => {
const [wabaId, setWabaId] = useState('');
const [accessToken, setAccessToken] = useState('');
const [templateName, setTemplateName] = useState('gallery_ready');
+ const [templateLanguage, setTemplateLanguage] = useState('');
const [enabled, setEnabled] = useState(false);
const [showToken, setShowToken] = useState(false);
const [testPhone, setTestPhone] = useState('');
@@ -43,6 +44,7 @@ export const WhatsAppTab: React.FC = () => {
// Leave it visible-as-masked so the admin sees that a token exists.
setAccessToken(data.access_token || '');
setTemplateName(data.template_name || 'gallery_ready');
+ setTemplateLanguage(data.template_language || '');
setEnabled(Boolean(data.enabled));
}
}, [data]);
@@ -53,6 +55,7 @@ export const WhatsAppTab: React.FC = () => {
waba_id: wabaId,
access_token: accessToken,
template_name: templateName,
+ template_language: templateLanguage,
enabled,
}),
onSuccess: () => {
@@ -175,6 +178,23 @@ export const WhatsAppTab: React.FC = () => {
+ {t(
+ 'settings.whatsapp.templateLanguageHint',
+ 'Meta template language code, exactly as you registered it in Meta Business Manager (`ar`, `en_US`, `de_DE`, `pt_BR`, etc.). Leave empty to fall back to the system default language. Meta returns "template not found in language" if this doesn\'t match a registered template.',
+ )}
+